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:
“BeeRad”
2025-12-15 16:14:28 +11:00
commit 733d1c3407
226 changed files with 46231 additions and 0 deletions
+243
View File
@@ -0,0 +1,243 @@
import { getSQLiteClient } from '@/services/database/sqlite-client';
import { eventBroadcaster } from '@/services/events';
export type DelegationStatus = 'queued' | 'in_progress' | 'completed' | 'failed';
export type DelegationAgentType = 'mini' | 'wise-rah';
export interface AgentDelegation {
id: number;
sessionId: string;
task: string;
context: string[];
expectedOutcome?: string | null;
status: DelegationStatus;
summary?: string | null;
agentType: DelegationAgentType;
supabaseToken?: string | null;
createdAt: string;
updatedAt: string;
}
function rowToDelegation(row: any): AgentDelegation {
return {
id: row.id,
sessionId: row.session_id,
task: row.task,
context: (() => {
try {
return row.context ? JSON.parse(row.context) : [];
} catch {
return [];
}
})(),
expectedOutcome: row.expected_outcome,
status: row.status as DelegationStatus,
summary: row.summary,
agentType: (row.agent_type || 'mini') as DelegationAgentType,
supabaseToken: row.supabase_token ?? null,
// SQLite CURRENT_TIMESTAMP is UTC, append 'Z' to parse correctly as UTC
createdAt: row.created_at ? row.created_at.replace(' ', 'T') + 'Z' : row.created_at,
updatedAt: row.updated_at ? row.updated_at.replace(' ', 'T') + 'Z' : row.updated_at,
};
}
function ensureTable() {
const db = getSQLiteClient();
db.query(`
CREATE TABLE IF NOT EXISTS agent_delegations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT UNIQUE NOT NULL,
task TEXT NOT NULL,
context TEXT,
expected_outcome TEXT,
status TEXT NOT NULL DEFAULT 'queued',
summary TEXT,
agent_type TEXT NOT NULL DEFAULT 'mini',
supabase_token TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
)
`);
// Add agent_type column if it doesn't exist (migration)
try {
const stmt = db.prepare('SELECT 1 FROM agent_delegations LIMIT 0');
const tableExists = stmt !== null;
if (tableExists) {
// Try to add the column, ignore if it already exists
try {
db.prepare(`ALTER TABLE agent_delegations ADD COLUMN agent_type TEXT NOT NULL DEFAULT 'mini'`).run();
console.log('✅ Added agent_type column to agent_delegations table');
} catch (alterError: any) {
// Column already exists, ignore
if (!alterError.message?.includes('duplicate column')) {
console.warn('Migration warning:', alterError.message);
}
}
try {
db.prepare(`ALTER TABLE agent_delegations ADD COLUMN supabase_token TEXT`).run();
console.log('✅ Added supabase_token column to agent_delegations table');
} catch (alterError: any) {
if (!alterError.message?.includes('duplicate column')) {
console.warn('Migration warning:', alterError.message);
}
}
}
} catch (error: any) {
// Table doesn't exist yet or other error - it will be created with the columns
console.log('Table creation will include agent_type and supabase_token columns');
}
}
export class AgentDelegationService {
static createDelegation(input: {
task: string;
context?: string[];
expectedOutcome?: string | null;
agentType?: DelegationAgentType;
supabaseToken?: string | null;
}): AgentDelegation {
ensureTable();
const db = getSQLiteClient();
const sessionId = `delegation_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const contextJson = JSON.stringify(input.context ?? []);
const agentType = input.agentType || 'mini';
db.prepare(
`INSERT INTO agent_delegations (session_id, task, context, expected_outcome, status, agent_type, supabase_token)
VALUES (?, ?, ?, ?, 'queued', ?, ?)`
).run(
sessionId,
input.task,
contextJson,
input.expectedOutcome ?? null,
agentType,
input.supabaseToken ?? null
);
const row = db
.prepare('SELECT * FROM agent_delegations WHERE session_id = ?')
.get(sessionId);
const delegation = rowToDelegation(row);
eventBroadcaster.broadcast({ type: 'AGENT_DELEGATION_CREATED', data: { delegation } });
return delegation;
}
static markInProgress(sessionId: string): AgentDelegation | null {
ensureTable();
const db = getSQLiteClient();
db.prepare(
`UPDATE agent_delegations
SET status = 'in_progress', updated_at = CURRENT_TIMESTAMP
WHERE session_id = ? AND status = 'queued'`
).run(sessionId);
const row = db.prepare('SELECT * FROM agent_delegations WHERE session_id = ?').get(sessionId);
if (!row) return null;
const delegation = rowToDelegation(row);
eventBroadcaster.broadcast({ type: 'AGENT_DELEGATION_UPDATED', data: { delegation } });
return delegation;
}
static touchDelegation(sessionId: string): void {
// Update the timestamp to prevent cleanup from killing active delegations
ensureTable();
const db = getSQLiteClient();
db.prepare(
`UPDATE agent_delegations SET updated_at = CURRENT_TIMESTAMP WHERE session_id = ? AND status = 'in_progress'`
).run(sessionId);
}
static completeDelegation(sessionId: string, summary: string, status: DelegationStatus = 'completed'): AgentDelegation | null {
ensureTable();
const db = getSQLiteClient();
db.prepare(
`UPDATE agent_delegations
SET status = ?, summary = ?, updated_at = CURRENT_TIMESTAMP, supabase_token = NULL
WHERE session_id = ?`
).run(status, summary, sessionId);
const row = db.prepare('SELECT * FROM agent_delegations WHERE session_id = ?').get(sessionId);
if (!row) return null;
const delegation = rowToDelegation(row);
eventBroadcaster.broadcast({ type: 'AGENT_DELEGATION_UPDATED', data: { delegation } });
return delegation;
}
static getBySessionId(sessionId: string): AgentDelegation | null {
ensureTable();
const db = getSQLiteClient();
const row = db.prepare('SELECT * FROM agent_delegations WHERE session_id = ?').get(sessionId);
return row ? rowToDelegation(row) : null;
}
static getDelegation(sessionId: string): AgentDelegation | null {
return this.getBySessionId(sessionId);
}
static listActive({ includeCompleted = true, limit = 100 }: { includeCompleted?: boolean; limit?: number } = {}): AgentDelegation[] {
ensureTable();
const db = getSQLiteClient();
// Load all delegations - user closes them manually from UI
const rows = includeCompleted
? db.prepare(
`SELECT * FROM agent_delegations
ORDER BY updated_at DESC
LIMIT ?`
).all(limit)
: db.prepare(
`SELECT * FROM agent_delegations
WHERE status IN ('queued','in_progress')
ORDER BY updated_at DESC
LIMIT ?`
).all(limit);
return rows.map(rowToDelegation);
}
static listRecent(limit = 20): AgentDelegation[] {
ensureTable();
const db = getSQLiteClient();
const rows = db.prepare(
`SELECT * FROM agent_delegations
ORDER BY created_at DESC
LIMIT ?`
).all(limit);
return rows.map(rowToDelegation);
}
static deleteDelegation(sessionId: string): boolean {
ensureTable();
const db = getSQLiteClient();
const result = db.prepare('DELETE FROM agent_delegations WHERE session_id = ?').run(sessionId);
return result.changes > 0;
}
static cleanupStaleDelegations(timeoutMinutes = 15): number {
ensureTable();
const db = getSQLiteClient();
const cutoffTime = new Date(Date.now() - timeoutMinutes * 60 * 1000).toISOString();
const result = db.prepare(`
UPDATE agent_delegations
SET status = 'failed',
summary = 'Task timed out (exceeded ${timeoutMinutes} minutes)',
updated_at = CURRENT_TIMESTAMP
WHERE status = 'in_progress'
AND updated_at < ?
`).run(cutoffTime);
const affectedCount = result.changes || 0;
if (affectedCount > 0) {
const rows = db.prepare(
`SELECT * FROM agent_delegations WHERE status = 'failed' AND summary LIKE 'Task timed out%'`
).all();
rows.forEach(row => {
const delegation = rowToDelegation(row);
eventBroadcaster.broadcast({ type: 'AGENT_DELEGATION_UPDATED', data: { delegation } });
});
}
return affectedCount;
}
}
+428
View File
@@ -0,0 +1,428 @@
import { generateText, CoreMessage } from 'ai';
import { createOpenAI } from '@ai-sdk/openai';
import { AgentDelegationService, DelegationStatus } from '@/services/agents/delegation';
import { MINI_RAH_SYSTEM_PROMPT } from '@/config/prompts/rah-mini';
import { getToolsForRole } from '@/tools/infrastructure/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';
interface CapsuleNodeJSON {
id: number;
}
interface DelegationCapsuleJSON {
version?: number;
primary?: CapsuleNodeJSON | null;
secondary?: CapsuleNodeJSON[];
referenced?: CapsuleNodeJSON[];
}
interface SummaryValidation {
status: 'ok' | 'failed';
reason?: string;
sourcesUsed: number[];
}
function extractCapsuleFromContext(context: string[]): { capsule?: DelegationCapsuleJSON; nodeIds: number[]; version?: number } {
const entry = context.find(item => typeof item === 'string' && item.startsWith('CAPSULE_JSON::'));
if (!entry) {
return { nodeIds: [] };
}
const json = entry.substring('CAPSULE_JSON::'.length);
try {
const capsule = JSON.parse(json) as DelegationCapsuleJSON & { version?: number };
const nodeIds = new Set<number>();
const pushId = (value?: CapsuleNodeJSON | null) => {
if (!value) return;
if (typeof value.id === 'number') {
nodeIds.add(value.id);
}
};
pushId(capsule.primary ?? null);
(capsule.secondary ?? []).forEach(pushId);
(capsule.referenced ?? []).forEach(pushId);
return {
capsule,
nodeIds: Array.from(nodeIds),
version: typeof capsule.version === 'number' ? capsule.version : undefined,
};
} catch (error) {
console.warn('MiniRAHExecutor: failed to parse delegation capsule', error);
return { nodeIds: [] };
}
}
function parseSourcesLine(summary: string): { line?: string; ids: number[] } {
const lines = summary
.split(/\n+/)
.map(line => line.trim())
.filter(Boolean);
const contextLine = lines.find(line => line.toLowerCase().startsWith('context sources used:'));
if (!contextLine) {
return { ids: [] };
}
const matches = contextLine.match(/\d+/g);
const ids = matches ? matches.map(id => Number(id)).filter(id => Number.isFinite(id)) : [];
return { line: contextLine, ids: Array.from(new Set(ids)) };
}
function validateMiniSummary(summary: string, expectedNodeIds: number[]): SummaryValidation {
const trimmed = summary.trim();
if (!trimmed) {
return { status: 'failed', reason: 'Worker returned an empty summary.', sourcesUsed: [] };
}
const lines = trimmed.split(/\n+/).map(line => line.trim()).filter(Boolean);
const resultIndex = lines.findIndex(line => line.toLowerCase().startsWith('result:'));
if (resultIndex === -1) {
return { status: 'failed', reason: 'Missing or empty Result line in worker summary.', sourcesUsed: [] };
}
const resultLine = lines[resultIndex];
const resultContent = resultLine.slice('result:'.length).trim();
if (!resultContent) {
const nextLine = lines[resultIndex + 1];
if (!nextLine || /^(task:|actions:|node:|context sources used:|follow-up:)/i.test(nextLine)) {
return { status: 'failed', reason: 'Missing or empty Result line in worker summary.', sourcesUsed: [] };
}
}
const followUpLine = lines.find(line => line.toLowerCase().startsWith('follow-up:'));
if (!followUpLine) {
return { status: 'failed', reason: 'Missing Follow-up line in worker summary.', sourcesUsed: [] };
}
const { line: contextLine, ids } = parseSourcesLine(summary);
if (!contextLine) {
return { status: 'failed', reason: 'Missing "Context sources used" line. Workers must list node IDs they referenced.', sourcesUsed: [] };
}
if (expectedNodeIds.length > 0 && ids.length === 0) {
return { status: 'failed', reason: 'Worker did not cite any node IDs even though a capsule was provided.', sourcesUsed: [] };
}
return { status: 'ok', sourcesUsed: ids };
}
export interface MiniRAHExecutionInput {
sessionId: string;
task: string;
context: string[];
expectedOutcome?: string | null;
traceId?: string;
parentChatId?: number;
workflowKey?: string;
workflowNodeId?: number;
}
export class MiniRAHExecutor {
static async execute({ sessionId, task, context, expectedOutcome, traceId, parentChatId, workflowKey, workflowNodeId }: MiniRAHExecutionInput) {
try {
const requestContext = RequestContext.get();
const delegateKey =
requestContext.apiKeys?.openai ||
process.env.RAH_DELEGATE_OPENAI_API_KEY ||
process.env.OPENAI_API_KEY;
if (!delegateKey) {
throw new Error('RAH_DELEGATE_OPENAI_API_KEY (or OPENAI_API_KEY) is not set.');
}
AgentDelegationService.markInProgress(sessionId);
const promptSections = [
`Task: ${task}`,
context.length ? `Context:\n- ${context.join('\n- ')}` : undefined,
expectedOutcome ? `Expected outcome: ${expectedOutcome}` : undefined,
'Return only the final summary the orchestrator should see.'
].filter(Boolean);
const openaiProvider = createOpenAI({ apiKey: delegateKey });
const executorTools = getToolsForRole('executor');
const capturedSummaries: string[] = [];
const toolsUsedInSession: string[] = [];
const integrateAllowed = workflowKey === 'integrate'
? new Set(['createEdge', 'updateEdge', 'updateNode'])
: null;
const wrappedTools = Object.fromEntries(
Object.entries(executorTools)
.filter(([name]) => {
if (integrateAllowed) {
return integrateAllowed.has(name);
}
return true;
})
.map(([name, tool]) => {
const wrapped = {
...tool,
async execute(params: any, context: any) {
if (!toolsUsedInSession.includes(name)) {
toolsUsedInSession.push(name);
}
if (name === 'createEdge' && params && typeof params === 'object' && 'from_node_id' in params && 'to_node_id' in params) {
const fromId = Number(params.from_node_id);
const toId = Number(params.to_node_id);
if (Number.isFinite(fromId) && Number.isFinite(toId)) {
const exists = await edgeService.edgeExists(fromId, toId);
if (exists) {
const skipSummary = `Edge already exists between node ${fromId} and node ${toId}; skipping createEdge.`;
capturedSummaries.push(skipSummary);
return {
success: true,
skipped: true,
message: skipSummary,
};
}
}
}
const result = await tool.execute(params, context);
const summary = summarizeToolExecution(name, params, result);
if (summary) {
capturedSummaries.push(summary);
}
return result;
}
};
return [name, wrapped];
})
);
if ('delegateToMiniRAH' in wrappedTools) {
console.warn('MiniRAHExecutor: delegateToMiniRAH detected in executor toolset. Removing to enforce single-level delegation.');
delete wrappedTools.delegateToMiniRAH;
}
const userPrompt = promptSections.join('\n\n');
const messages: CoreMessage[] = [{ role: 'user', content: userPrompt }];
const maxIterations = 6;
let rawSummary = '';
let lastFinishReason: string | undefined;
let lastToolCalls: any[] | undefined;
let totalInputTokens = 0;
let totalOutputTokens = 0;
let totalTokens = 0;
// Track if extraction tool was called (for Quick Add - should only call once)
const isQuickAddTask = task.toLowerCase().includes('quick add');
let extractionToolCalled = false;
for (let iteration = 0; iteration < maxIterations; iteration++) {
const response = await generateText({
model: openaiProvider('gpt-4o-mini'),
system: MINI_RAH_SYSTEM_PROMPT,
messages,
tools: wrappedTools,
});
const usage = response.usage;
if (usage) {
const inputTokens = (usage as any).promptTokens || usage.inputTokens || 0;
const outputTokens = (usage as any).completionTokens || usage.outputTokens || 0;
const combinedTotal = (usage as any).totalTokens || usage.totalTokens || inputTokens + outputTokens;
totalInputTokens += inputTokens;
totalOutputTokens += outputTokens;
totalTokens += combinedTotal;
}
lastFinishReason = response.finishReason;
lastToolCalls = response.toolCalls ?? [];
if (response.finishReason === 'tool-calls' && response.toolCalls && response.toolCalls.length > 0) {
messages.push({
role: 'assistant',
content: response.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: { type: 'text' | 'error-text'; value: string } }> = [];
for (const call of response.toolCalls) {
const callInput = (call as any).input ?? (call as any).args;
const tool = wrappedTools[call.toolName];
const isExtractionToolCall = isQuickAddTask && ['youtubeExtract', 'websiteExtract', 'paperExtract'].includes(call.toolName);
if (isExtractionToolCall && extractionToolCalled) {
const skipMessage = `Extraction already completed; skipping duplicate ${call.toolName} request.`;
console.warn(`[MiniRAHExecutor] ${skipMessage}`);
toolResults.push({
type: 'tool-result',
toolCallId: call.toolCallId,
toolName: call.toolName,
output: { type: 'text', value: skipMessage },
});
continue;
}
if (!tool) {
toolResults.push({
type: 'tool-result',
toolCallId: call.toolCallId,
toolName: call.toolName,
output: { type: 'error-text', value: `Tool ${call.toolName} is not available.` },
});
continue;
}
try {
const toolResult = await tool.execute(callInput, {});
const summary = summarizeToolExecution(call.toolName, callInput, toolResult);
const value = summary || `${call.toolName} completed.`;
toolResults.push({
type: 'tool-result',
toolCallId: call.toolCallId,
toolName: call.toolName,
output: { type: 'text', value },
});
// For Quick Add: stop after first successful extraction to prevent duplicates
if (isExtractionToolCall) {
const success = typeof toolResult === 'object' && toolResult !== null && (toolResult as any).success !== false;
if (success) {
console.log(`[MiniRAHExecutor] Quick Add extraction succeeded, forcing summary generation to prevent duplicate calls`);
extractionToolCalled = true;
break;
}
}
} 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 },
});
}
}
messages.push({ role: 'tool', content: toolResults });
// If Quick Add extraction succeeded, request final summary and exit loop
if (extractionToolCalled) {
console.log('[MiniRAHExecutor] Requesting final summary after Quick Add extraction');
messages.push({
role: 'user',
content: 'Extraction completed successfully. Provide your final summary now using the required format (Task/Actions/Result/Node/Context sources used/Follow-up).'
});
const summaryResponse = await generateText({
model: openaiProvider('gpt-4o-mini'),
system: MINI_RAH_SYSTEM_PROMPT,
messages,
tools: {}, // No tools - summary only
});
rawSummary = summaryResponse.text?.trim() || 'Extraction completed successfully.';
break;
}
continue;
}
rawSummary = typeof response.text === 'string' ? response.text.trim() : '';
if (!rawSummary) {
console.warn('[MiniRAHExecutor] Worker returned empty summary.', {
finishReason: response.finishReason,
toolCalls: response.toolCalls,
text: response.text,
});
}
break;
}
if (!rawSummary) {
console.warn('[MiniRAHExecutor] No summary after tool loop.', {
lastFinishReason,
lastToolCalls,
});
}
const fallbackSummary = capturedSummaries.length > 0
? capturedSummaries[capturedSummaries.length - 1]
: 'Completed the delegated task (worker returned no additional summary).';
const initialSummary = rawSummary.length > 0 ? rawSummary : fallbackSummary;
const capsuleInfo = extractCapsuleFromContext(context);
const validation = validateMiniSummary(initialSummary, capsuleInfo.nodeIds);
const validationStatus: DelegationStatus = validation.status === 'ok' ? 'completed' : 'failed';
if (validation.status === 'failed') {
console.warn('[MiniRAHExecutor] summary validation failed.', {
reason: validation.reason,
initialSummary,
});
}
const finalSummary = validation.status === 'ok'
? initialSummary
: `${initialSummary}\nValidation: ${validation.reason}`;
console.log('[MiniRAHExecutor] summary:', finalSummary);
// Calculate cost and log to chats table
if (totalInputTokens > 0 || totalOutputTokens > 0 || totalTokens > 0) {
const effectiveTotalTokens = totalTokens > 0 ? totalTokens : totalInputTokens + totalOutputTokens;
const costResult = calculateCost({
inputTokens: totalInputTokens,
outputTokens: totalOutputTokens,
modelId: 'gpt-4o-mini',
});
const usageData: UsageData = {
inputTokens: totalInputTokens,
outputTokens: totalOutputTokens,
totalTokens: effectiveTotalTokens,
estimatedCostUsd: costResult.totalCostUsd,
modelUsed: 'gpt-4o-mini',
provider: 'openai',
toolsUsed: toolsUsedInSession.length > 0 ? toolsUsedInSession : undefined,
toolCallsCount: toolsUsedInSession.length > 0 ? toolsUsedInSession.length : undefined,
traceId,
parentChatId,
workflowKey,
workflowNodeId,
capsuleVersion: capsuleInfo.version,
contextSourcesUsed: validation.sourcesUsed.length > 0 ? validation.sourcesUsed : undefined,
validationStatus: validation.status,
validationMessage: validation.reason,
fallbackAction: validation.status === 'failed' ? 'Review context capsule, hydrate nodes manually, then re-delegate with clarified instructions.' : undefined,
};
const delegation = AgentDelegationService.getDelegation(sessionId);
const delegationId = delegation?.id;
await ChatLoggingMiddleware.logChatInteraction(
task,
finalSummary,
{
helperName: 'mini-rah',
agentType: 'executor',
delegationId: delegationId ?? null,
sessionId,
usageData,
traceId,
parentChatId,
workflowKey,
workflowNodeId,
systemMessage: MINI_RAH_SYSTEM_PROMPT,
},
[]
);
console.log(`💰 [MiniRAHExecutor] Cost: $${costResult.totalCostUsd.toFixed(6)} (${effectiveTotalTokens} tokens)`);
}
return AgentDelegationService.completeDelegation(sessionId, finalSummary, validationStatus);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown delegation error';
AgentDelegationService.completeDelegation(sessionId, `Mini ra-h failed: ${message}`, 'failed');
throw error;
}
}
}
+374
View File
@@ -0,0 +1,374 @@
import { AgentDelegationService } from './delegation';
import { summarizeToolExecution } from './toolResultUtils';
import { youtubeExtractTool } from '@/tools/other/youtubeExtract';
import { websiteExtractTool } from '@/tools/other/websiteExtract';
import { paperExtractTool } from '@/tools/other/paperExtract';
import { formatNodeForChat } from '@/tools/infrastructure/nodeFormatter';
import { summarizeTranscript } from './transcriptSummarizer';
export type QuickAddMode = 'link' | 'note' | 'chat';
export type QuickAddInputType = 'youtube' | 'website' | 'pdf' | 'note' | 'chat';
export interface QuickAddInput {
rawInput: string;
mode?: QuickAddMode;
}
function isLikelyChatTranscript(raw: string): boolean {
const newlineCount = (raw.match(/\n/g)?.length ?? 0);
if (newlineCount >= 3 && raw.length > 300) return true;
if (/\b\d{1,2}:\d{2}\b/.test(raw) && newlineCount >= 1) return true;
if (/You said:|ChatGPT said:|Claude said:|Assistant:|User:/i.test(raw)) return true;
return false;
}
export function detectInputType(raw: string, mode?: QuickAddMode): QuickAddInputType {
if (mode === 'chat') return 'chat';
if (mode === 'note') return 'note';
const input = raw.trim();
if (/youtu(\.be|be\.com)/i.test(input)) return 'youtube';
if (/\.pdf($|\?)/i.test(input) || /arxiv\.org\//i.test(input)) return 'pdf';
if (/^https?:\/\//i.test(input)) return 'website';
if (!mode && isLikelyChatTranscript(input)) return 'chat';
return 'note';
}
function buildTaskPrompt(type: QuickAddInputType, input: string): string {
switch (type) {
case 'youtube':
return `Quick Add: extract YouTube video and create node → ${input}`;
case 'website':
return `Quick Add: extract webpage and create node → ${input}`;
case 'pdf':
return `Quick Add: extract PDF and create node → ${input}`;
case 'note':
return `Quick Add note: create a node from this text with no dimensions → ${input}`;
case 'chat':
return `Quick Add: import chat transcript and summarize → ${input.slice(0, 120)}${input.length > 120 ? '…' : ''}`;
}
}
function buildExpectedOutcome(type: QuickAddInputType): string {
if (type === 'note') {
return 'Call createNode with the user input as content. Use a descriptive title extracted from the first few words. Set dimensions to an empty array. Return a brief confirmation like "Created note [NODE:id:title]"';
}
if (type === 'chat') {
return 'Store the entire transcript in chunk_content, summarize the conversation into content, and return a confirmation like "Created chat transcript node [NODE:id:title]"';
}
return 'Use the appropriate extraction tool (youtubeExtract, websiteExtract, or paperExtract) to create the node. Return the standard extraction summary.';
}
type ExtractionQuickAddType = Extract<QuickAddInputType, 'youtube' | 'website' | 'pdf'>;
const EXTRACTION_TOOL_MAP = {
youtube: { toolName: 'youtubeExtract' as const, execute: youtubeExtractTool.execute },
website: { toolName: 'websiteExtract' as const, execute: websiteExtractTool.execute },
pdf: { toolName: 'paperExtract' as const, execute: paperExtractTool.execute },
};
interface SummaryParts {
task: string;
action: string;
resultMessage: string;
nodeReference: string;
}
interface ExtractionToolResultData {
nodeId?: number;
title?: string;
}
interface ExtractionToolResult {
success?: boolean;
error?: string;
data?: ExtractionToolResultData | null;
message?: string;
}
interface CreateNodeResponse {
success?: boolean;
data?: { id?: number; title?: string } | null;
error?: string;
}
function buildStructuredSummary({ task, action, resultMessage, nodeReference }: SummaryParts): string {
const normalizedResult = resultMessage?.trim().length ? resultMessage.trim() : `${action} completed.`;
const normalizedNode = nodeReference || 'None';
return [
`Task: ${task}`,
`Actions: ${action}`,
`Result: ${normalizedResult}`,
`Node: ${normalizedNode}`,
'Context sources used: None',
'Follow-up: None',
].join('\n');
}
function deriveNoteTitle(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {
return 'Quick Add Note';
}
const sentenceMatch = trimmed.match(/^(.{1,120}?)([.!?]\s|\n|$)/);
const candidate = sentenceMatch ? sentenceMatch[1] : trimmed.slice(0, 120);
const title = candidate.replace(/\s+/g, ' ').trim();
return title.length >= trimmed.length || title.length <= 120 ? title : `${title.slice(0, 117)}`;
}
function deriveChatTitle(raw: string, summarySubject?: string): string {
if (summarySubject && summarySubject.trim().length > 0) {
return summarySubject.trim();
}
const trimmed = raw.trim();
if (!trimmed) return 'Chat Transcript';
const firstLine = trimmed.split('\n')[0];
const cleaned = firstLine.replace(/You said:|ChatGPT said:|Claude said:/gi, '').trim();
if (!cleaned) return 'Chat Transcript';
return cleaned.length > 120 ? `${cleaned.slice(0, 117)}` : cleaned;
}
function isExtractionToolResult(value: unknown): value is ExtractionToolResult {
if (!value || typeof value !== 'object') {
return false;
}
const candidate = value as Record<string, unknown>;
if ('success' in candidate && typeof candidate.success !== 'boolean' && candidate.success !== undefined) {
return false;
}
if ('error' in candidate && typeof candidate.error !== 'string' && candidate.error !== undefined && candidate.error !== null) {
return false;
}
if ('data' in candidate && candidate.data !== undefined && candidate.data !== null && typeof candidate.data !== 'object') {
return false;
}
return true;
}
function isCreateNodeResponse(value: unknown): value is CreateNodeResponse {
if (!value || typeof value !== 'object') {
return false;
}
const candidate = value as Record<string, unknown>;
if ('success' in candidate && typeof candidate.success !== 'boolean' && candidate.success !== undefined) {
return false;
}
if ('error' in candidate && typeof candidate.error !== 'string' && candidate.error !== undefined && candidate.error !== null) {
return false;
}
if ('data' in candidate && candidate.data !== undefined && candidate.data !== null && typeof candidate.data !== 'object') {
return false;
}
return true;
}
async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: string, task: string): Promise<string> {
const { toolName, execute } = EXTRACTION_TOOL_MAP[type];
if (!execute) {
throw new Error(`Tool ${toolName} does not have an execute function`);
}
const rawResult = await execute({ url }, { toolCallId: 'quickadd-extract', messages: [] });
if (!isExtractionToolResult(rawResult)) {
throw new Error(`Unexpected response from ${toolName}`);
}
const toolResult = rawResult;
if (!toolResult || toolResult.success === false) {
const errorMessage = toolResult?.error || `Failed to execute ${toolName}`;
throw new Error(errorMessage);
}
const summaryLine = summarizeToolExecution(toolName, { url }, toolResult);
const nodeId = toolResult.data?.nodeId;
const nodeTitle = typeof toolResult.data?.title === 'string' && toolResult.data.title.trim().length > 0
? toolResult.data.title.trim()
: nodeId ? `Node ${nodeId}` : 'Created node';
const nodeReference = nodeId ? formatNodeForChat({ id: nodeId, title: nodeTitle }) : 'None';
return buildStructuredSummary({
task,
action: toolName,
resultMessage: summaryLine,
nodeReference,
});
}
async function handleNoteQuickAdd(rawInput: string, task: string): Promise<string> {
const content = rawInput.trim();
if (!content) {
throw new Error('Input is required to create a note');
}
const title = deriveNoteTitle(content);
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
content,
dimensions: [],
metadata: {
source: 'quick-add-note',
refined_at: new Date().toISOString(),
},
}),
});
const rawResult = await response.json();
if (!isCreateNodeResponse(rawResult)) {
throw new Error('Unexpected response from node creation');
}
if (!response.ok) {
throw new Error(rawResult?.error || 'Failed to create note');
}
const nodeId = rawResult?.data?.id;
const nodeReference = nodeId ? formatNodeForChat({ id: nodeId, title }) : 'None';
const resultMessage = nodeId ? `Created note ${nodeReference}.` : 'Created note.';
return buildStructuredSummary({
task,
action: 'createNode',
resultMessage,
nodeReference,
});
}
async function handleChatTranscriptQuickAdd(rawInput: string, task: string): Promise<string> {
const transcript = rawInput.trim();
if (!transcript) {
throw new Error('Input is required to import a chat transcript');
}
const summaryResult = await summarizeTranscript(transcript);
const baseSummary = summaryResult.summary?.trim() || 'Captured chat transcript. Review the raw transcript for full detail.';
const intentLine = summaryResult.intent?.trim();
const progressLine = summaryResult.progress?.trim();
const stickingPoints = summaryResult.stickingPoints || [];
const highlightSection = (summaryResult.highlights?.length ?? 0) > 0
? ['Highlights:', ...summaryResult.highlights!.map((item) => `- ${item}`)].join('\n')
: null;
const followUpSection = (summaryResult.openQuestions?.length ?? 0) > 0
? ['Open Questions:', ...summaryResult.openQuestions!.map((item) => `- ${item}`)].join('\n')
: null;
const stickingSection = stickingPoints.length > 0
? ['Where things felt stuck:', ...stickingPoints.map((item) => `- ${item}`)].join('\n')
: null;
const contentParts = [
`Overview:\n${baseSummary}`,
];
if (intentLine) {
contentParts.push(`What you were trying to do:\n${intentLine}`);
}
if (progressLine) {
contentParts.push(`Where you made progress:\n${progressLine}`);
}
if (stickingSection) {
contentParts.push(stickingSection);
}
if (highlightSection) contentParts.push(highlightSection);
if (followUpSection) contentParts.push(followUpSection);
const content = contentParts.join('\n\n');
const title = deriveChatTitle(transcript, summaryResult.subject);
const wordCount = transcript.split(/\s+/).filter(Boolean).length;
const metadata = {
source: 'quick-add-chat',
summary_subject: summaryResult.subject,
summary_intent: summaryResult.intent,
summary_progress: summaryResult.progress,
highlights: summaryResult.highlights ?? [],
open_questions: summaryResult.openQuestions ?? [],
participants: summaryResult.participants ?? [],
sticking_points: summaryResult.stickingPoints ?? [],
transcript_length_chars: transcript.length,
transcript_length_words: wordCount,
transcript_truncated_for_summary: summaryResult.truncated ?? false,
summary_generated_at: new Date().toISOString(),
};
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
content,
chunk: transcript,
dimensions: [],
metadata,
}),
});
const rawResult = await response.json();
if (!isCreateNodeResponse(rawResult)) {
throw new Error('Unexpected response from node creation');
}
if (!response.ok) {
throw new Error(rawResult?.error || 'Failed to create chat transcript node');
}
const nodeId = rawResult?.data?.id;
const nodeReference = nodeId ? formatNodeForChat({ id: nodeId, title }) : 'None';
const resultMessage = nodeId ? `Created chat transcript ${nodeReference}.` : 'Created chat transcript.';
return buildStructuredSummary({
task,
action: 'chatTranscriptImport',
resultMessage,
nodeReference,
});
}
export async function enqueueQuickAdd({ rawInput, mode }: QuickAddInput) {
const inputType = detectInputType(rawInput, mode);
const context: string[] = (inputType === 'note' || inputType === 'chat') ? [] : [rawInput];
const task = buildTaskPrompt(inputType, rawInput);
const expectedOutcome = buildExpectedOutcome(inputType);
const delegation = AgentDelegationService.createDelegation({
task,
context,
expectedOutcome,
});
setImmediate(async () => {
try {
AgentDelegationService.markInProgress(delegation.sessionId);
let summary: string;
if (inputType === 'note') {
summary = await handleNoteQuickAdd(rawInput, task);
} else if (inputType === 'chat') {
summary = await handleChatTranscriptQuickAdd(rawInput, task);
} else {
summary = await handleExtractionQuickAdd(inputType as ExtractionQuickAddType, rawInput, task);
}
AgentDelegationService.completeDelegation(delegation.sessionId, summary, 'completed');
} catch (error) {
const message = error instanceof Error ? error.message : 'unknown error';
AgentDelegationService.completeDelegation(
delegation.sessionId,
`Quick Add failed: ${message}`,
'failed'
);
}
});
return delegation;
}
+107
View File
@@ -0,0 +1,107 @@
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 { MINI_RAH_SYSTEM_PROMPT } from '@/config/prompts/rah-mini';
import { WISE_RAH_SYSTEM_PROMPT } from '@/config/prompts/wise-rah';
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
},
'mini-rah': {
id: 2,
key: 'mini-rah',
displayName: 'mini ra-h',
description: 'Executor agent for delegated tasks',
model: 'openai/gpt-4o-mini',
role: 'executor',
systemPrompt: MINI_RAH_SYSTEM_PROMPT,
availableTools: getDefaultToolNamesForRole('executor'),
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
memory: null,
prompts: undefined
},
'wise-rah': {
id: 3,
key: 'wise-rah',
displayName: 'wise ra-h',
description: 'Complex workflow planner and orchestrator',
model: 'openai/gpt-5',
role: 'planner',
systemPrompt: WISE_RAH_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 executor(): Promise<AgentDefinition> {
return this.AGENTS['mini-rah'];
}
static async planner(): Promise<AgentDefinition> {
return this.AGENTS['wise-rah'];
}
}
+132
View File
@@ -0,0 +1,132 @@
function ensureString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function ensureNumber(value: unknown): number | undefined {
return typeof value === 'number' ? value : undefined;
}
function truncateOrDefault(value: string, limit = 180, fallback = ''): string {
if (!value) return fallback;
if (value.length <= limit) return value;
return `${value.slice(0, limit - 1)}`;
}
export function summarizeToolExecution(toolName: string, args: any, result: any): string {
const fallback = `${toolName} completed.`;
if (typeof result === 'string') {
const trimmed = result.trim();
return trimmed || fallback;
}
if (!result || typeof result !== 'object') {
return fallback;
}
if (result.success === false) {
const error = ensureString(result.error) || 'unknown error';
return `${toolName} failed: ${error}`;
}
const message = ensureString((result as any).message);
if (message) {
return message;
}
if (toolName === 'think') {
const trace = (result as any).data?.trace ?? args ?? {};
const step = ensureNumber(trace.step ?? args?.step);
const purpose = ensureString(trace.purpose ?? args?.purpose) || 'planning';
const thoughts = ensureString(trace.thoughts ?? args?.thoughts);
const next = ensureString(trace.next_action ?? args?.next_action);
let summary = `Plan${step ? ` step ${step}` : ''}: ${truncateOrDefault(purpose, 120, purpose)}`;
if (thoughts) {
summary += `${truncateOrDefault(thoughts, 160, thoughts)}`;
}
if (next) {
summary += `. Next: ${truncateOrDefault(next, 80, next)}`;
}
return summary;
}
if (toolName === 'webSearch') {
const query = ensureString(args?.query) || ensureString(result.data?.query);
const results = Array.isArray(result.data?.results) ? result.data.results : [];
if (results.length > 0) {
const items = results.slice(0, 3).map((entry: any) => {
const title = ensureString(entry.title) || ensureString(entry.url) || 'Result';
const url = ensureString(entry.url);
return url ? `${truncateOrDefault(title, 80, title)} (${url})` : truncateOrDefault(title, 80, title);
});
return `Web search${query ? ` for "${truncateOrDefault(query, 60, query)}"` : ''}: ${items.join('; ')}`;
}
return `Web search${query ? ` for "${truncateOrDefault(query, 60, query)}"` : ''}: no results.`;
}
if (toolName === 'searchContentEmbeddings') {
const query = ensureString(args?.query) || ensureString(result.data?.query);
const chunks = Array.isArray(result.data?.chunks) ? result.data.chunks : [];
if (chunks.length > 0) {
const top = chunks[0];
const snippet = ensureString(top.text);
const nodeId = ensureNumber(top.node_id);
const preview = truncateOrDefault(snippet, 160, snippet);
return `Embedding search${query ? ` for "${truncateOrDefault(query, 60, query)}"` : ''} found ${chunks.length} chunk(s). Top${nodeId ? ` [NODE:${nodeId}]` : ''}: ${preview}`;
}
return `Embedding search${query ? ` for "${truncateOrDefault(query, 60, query)}"` : ''}: no matches.`;
}
if (toolName === 'youtubeExtract') {
const title = ensureString(result.data?.title) || ensureString(args?.title);
const formatted = ensureString(result.data?.formatted_display);
if (formatted) {
return `YouTube extract created ${formatted}.`;
}
if (title) {
return `YouTube extract processed "${truncateOrDefault(title, 80, title)}".`;
}
return 'YouTube extract completed.';
}
if (toolName === 'queryNodes') {
const nodes = Array.isArray(result.data?.nodes) ? result.data.nodes : [];
if (nodes.length > 0) {
const labels = nodes
.slice(0, 3)
.map((node: any) => ensureString(node.formatted_display) || ensureString(node.title) || `[NODE:${node.id}]`)
.join(', ');
return `Found ${nodes.length} node(s): ${labels}`;
}
}
if (toolName === 'queryEdge') {
const edges = Array.isArray(result.data?.edges) ? result.data.edges : [];
if (edges.length > 0) {
const edge = edges[0];
return `Found ${edges.length} edge(s), e.g., ${edge.from_node_id}${edge.to_node_id}.`;
}
return 'No edges found.';
}
if (result.data?.formatted_display) {
return ensureString(result.data.formatted_display) || fallback;
}
if (result.data?.title) {
return `Processed "${truncateOrDefault(ensureString(result.data.title), 80, result.data.title)}".`;
}
if (result.data?.count !== undefined) {
const count = result.data.count;
return `${toolName} returned ${count} item(s).`;
}
try {
const preview = JSON.stringify(result.data ?? result);
return truncateOrDefault(preview, 200, fallback);
} catch (error) {
return fallback;
}
}
@@ -0,0 +1,93 @@
import { generateText } from 'ai';
import { openai } from '@ai-sdk/openai';
export interface TranscriptSummaryResult {
subject?: string;
summary?: string;
intent?: string;
progress?: string;
highlights?: string[];
openQuestions?: string[];
participants?: string[];
stickingPoints?: string[];
truncated?: boolean;
}
const MAX_TRANSCRIPT_CHARS = 15000;
function buildPrompt(snippet: string) {
return `You will receive a raw conversation transcript copy/pasted from various chat apps. It may contain timestamps, "You said:", "ChatGPT said:", missing speaker labels, or UI noise like "Thought for 11s".
Return ONLY valid JSON with this exact shape (no markdown, no commentary):
{
"subject": "Short descriptive thread title",
"summary": "2-3 sentence narrative describing what was discussed and why it matters",
"intent": "What the human seemed to be trying to accomplish (1 sentence, second-person)",
"progress": "Where the conversation actually moved forward (1-2 sentences, second-person)",
"stickingPoints": ["Concise phrases describing where you got stuck or needed help"],
"highlights": ["Key takeaway 1", "Key takeaway 2"],
"openQuestions": ["Follow-up 1"],
"participants": ["user", "assistant"]
}
Guidelines:
- Never mention the formatting noise; describe the substance.
- Keep highlights concise bullet phrases (<=12 words).
- Sticking points should be authentic blockers, not restatements of highlights.
- Open questions are the clearest next steps; omit the array when nothing actionable exists.
- Participants should be lowercase role labels (user, assistant, reviewer, etc.).
- Prefer phrasing that sounds like you're talking directly to the user ("you were trying to...").
Transcript:
${snippet}`;
}
export async function summarizeTranscript(transcript: string): Promise<TranscriptSummaryResult> {
const limited = transcript.length > MAX_TRANSCRIPT_CHARS
? transcript.slice(0, MAX_TRANSCRIPT_CHARS)
: transcript;
try {
const response = await generateText({
model: openai('gpt-4o-mini'),
prompt: buildPrompt(limited),
maxOutputTokens: 600,
});
let content = response.text || '';
content = content.replace(/```json/gi, '').replace(/```/g, '').trim();
const parsed = JSON.parse(content);
return {
subject: typeof parsed.subject === 'string' ? parsed.subject : undefined,
summary: typeof parsed.summary === 'string' ? parsed.summary : undefined,
intent: typeof parsed.intent === 'string' ? parsed.intent : undefined,
progress: typeof parsed.progress === 'string' ? parsed.progress : undefined,
highlights: Array.isArray(parsed.highlights)
? parsed.highlights.filter((h: unknown) => typeof h === 'string' && h.trim().length > 0)
: [],
openQuestions: Array.isArray(parsed.openQuestions)
? parsed.openQuestions.filter((q: unknown) => typeof q === 'string' && q.trim().length > 0)
: [],
participants: Array.isArray(parsed.participants)
? parsed.participants.filter((p: unknown) => typeof p === 'string' && p.trim().length > 0)
: [],
stickingPoints: Array.isArray(parsed.stickingPoints)
? parsed.stickingPoints.filter((s: unknown) => typeof s === 'string' && s.trim().length > 0)
: [],
truncated: transcript.length > MAX_TRANSCRIPT_CHARS,
};
} catch (error) {
console.warn('[TranscriptSummarizer] Failed to summarize transcript, falling back', error);
return {
summary: '',
intent: '',
progress: '',
highlights: [],
openQuestions: [],
participants: [],
stickingPoints: [],
truncated: transcript.length > MAX_TRANSCRIPT_CHARS,
};
}
}
+25
View File
@@ -0,0 +1,25 @@
export type AgentRole = 'orchestrator' | 'executor' | 'planner';
export interface AgentDefinition {
id: number;
key: string;
displayName: string;
description: string | null;
model: string;
role: AgentRole;
systemPrompt: string;
availableTools: string[];
enabled: boolean;
createdAt: string;
updatedAt: string;
memory?: Record<string, unknown> | null;
prompts?: Array<{ id: string; name: string; content: string }>;
}
export interface AgentSummary {
key: string;
displayName: string;
role: AgentRole;
model: string;
enabled: boolean;
}
+709
View File
@@ -0,0 +1,709 @@
import { streamText, CoreMessage } from 'ai';
import { createOpenAI } from '@ai-sdk/openai';
import type { LanguageModelV2ToolResultOutput } from '@ai-sdk/provider';
import { AgentDelegationService } from '@/services/agents/delegation';
import { WISE_RAH_SYSTEM_PROMPT } from '@/config/prompts/wise-rah';
import { getToolsForRole } from '@/tools/infrastructure/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 { eventBroadcaster } from '@/services/events';
import { delegationStreamBroadcaster } from '@/app/api/rah/delegations/stream/route';
import { nodeService } from '@/services/database/nodes';
import { RequestContext } from '@/services/context/requestContext';
import { isLocalMode } from '@/config/runtime';
export interface WiseRAHExecutionInput {
sessionId: string;
task: string;
context: string[];
expectedOutcome?: string | null;
traceId?: string;
parentChatId?: number;
workflowKey?: string;
workflowNodeId?: number;
}
export class WiseRAHExecutor {
static async execute({ sessionId, task, context, expectedOutcome, traceId, parentChatId, workflowKey, workflowNodeId }: WiseRAHExecutionInput) {
console.log('🧙 [WiseRAHExecutor] Starting execution', { sessionId, task: task.substring(0, 100) });
try {
const requestContext = RequestContext.get();
const wiseRahKey =
requestContext.apiKeys?.openai ||
process.env.RAH_WISE_RAH_OPENAI_API_KEY ||
process.env.OPENAI_API_KEY;
if (!wiseRahKey) {
throw new Error('RAH_WISE_RAH_OPENAI_API_KEY (or OPENAI_API_KEY) is not set.');
}
AgentDelegationService.markInProgress(sessionId);
console.log('✅ [WiseRAHExecutor] Delegation marked in progress');
const normalizedTask = task.toLowerCase();
const normalizedOutcome = (expectedOutcome || '').toLowerCase();
const isWorkflow = Boolean(workflowKey) || normalizedTask.startsWith('execute workflow');
const explicitWriteRequest = /\b(create|update|edge|append|workflow|integrate|summarize into node|link node|embed|ingest|synchronize)\b/.test(normalizedTask) ||
/\b(create|update|edge|append|workflow|integrate|summarize into node|link node|embed|ingest|synchronize)\b/.test(normalizedOutcome);
const allowWrites = isWorkflow || explicitWriteRequest;
const analysisOnly = !allowWrites;
const maxIterationsLimit = isWorkflow ? 20 : 5;
const maxDelegationsAllowed = allowWrites ? (isWorkflow ? 12 : 2) : 0;
const maxDistinctWebSearches = isWorkflow ? 6 : 4;
const maxDistinctEmbeddingSearches = isWorkflow ? 5 : 3;
const promptSections = [
`Task: ${task}`,
context.length ? `Context:\n- ${context.join('\n- ')}` : undefined,
expectedOutcome ? `Expected outcome: ${expectedOutcome}` : undefined,
analysisOnly ? 'Constraint: This is an analysis-only request. Stay strictly read-only: do not call delegateToMiniRAH and do not request new extractions. Work only with existing knowledge.' : undefined,
'Return a structured summary following the format in your system prompt (Task/Actions/Result/Nodes/Follow-up).'
].filter(Boolean);
const openaiProvider = createOpenAI({ apiKey: wiseRahKey });
console.log('🔧 [WiseRAHExecutor] OpenAI provider created');
const plannerTools = getToolsForRole('planner');
// Remove delegateToMiniRAH for integrate workflow (wise-rah does updates directly)
if (workflowKey === 'integrate' && plannerTools.delegateToMiniRAH) {
delete plannerTools.delegateToMiniRAH;
console.log('🚫 [WiseRAHExecutor] Removed delegateToMiniRAH for integrate workflow (direct updates only)');
}
// For analysis-only tasks, also remove delegation
if (analysisOnly && plannerTools.delegateToMiniRAH) {
delete plannerTools.delegateToMiniRAH;
}
console.log('🛠️ [WiseRAHExecutor] Planner tools retrieved:', Object.keys(plannerTools));
const toolsUsedInSession: string[] = [];
const delegatedEdgeKeys = new Set<string>();
// Workflow progress is now streamed directly to delegation tabs via delegationStreamBroadcaster
// No need to broadcast WORKFLOW_PROGRESS events to main chat anymore
const wrappedTools = Object.fromEntries(
Object.entries(plannerTools).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];
})
);
// Enforce read-only constraint - remove write tools EXCEPT for workflows
// Workflows (like integrate) need updateNode for direct content updates
const writeToolsToRemove = isWorkflow
? ['createNode', 'createEdge', 'updateEdge', 'embedContent', 'youtubeExtract', 'websiteExtract', 'paperExtract', 'delegateToWiseRAH']
: ['createNode', 'updateNode', 'createEdge', 'updateEdge', 'embedContent', 'youtubeExtract', 'websiteExtract', 'paperExtract', 'delegateToWiseRAH'];
writeToolsToRemove.forEach(toolName => {
if (toolName in wrappedTools) {
console.warn(`WiseRAHExecutor: ${toolName} detected in planner toolset. Removing to enforce read-only constraint.`);
delete wrappedTools[toolName];
}
});
if (isWorkflow && 'updateNode' in wrappedTools) {
console.log('✅ [WiseRAHExecutor] updateNode preserved for workflow execution');
}
console.log('🔒 [WiseRAHExecutor] Final tools after read-only enforcement:', Object.keys(wrappedTools));
console.log('📝 [WiseRAHExecutor] Starting manual agentic loop...');
const messages: CoreMessage[] = [
{ role: 'system', content: WISE_RAH_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 workerSummarySet = new Set<string>();
let hasPlan = false;
let planReminderAdded = false;
let planIncludesDelegation = false;
let planRevisionNoticeSent = false;
let delegationNudgeSent = false;
let iterationsSincePlan = 0;
let lastPlanSummary = '';
let totalDelegations = 0;
let didCreateEdge = false;
let didUpdateNode = false;
const uniqueWebQueries = new Set<string>();
const uniqueEmbeddingQueries = new Set<string>();
let finalSummaryRequested = false;
let iterationsWithoutDelegation = 0;
const ensureString = (value: unknown) => (typeof value === 'string' ? value.trim() : '');
const sanitizeForBroadcast = (value: unknown) => {
if (value === undefined) return undefined;
try {
return JSON.parse(JSON.stringify(value));
} catch (error) {
console.warn('[WiseRAHExecutor] Failed to serialize delegation payload', error);
if (typeof value === 'string') return value;
return undefined;
}
};
const emitDelegationEvent = (payload: Record<string, unknown>) => {
delegationStreamBroadcaster.broadcast(sessionId, payload);
};
const emitToolStart = (toolCallId: string, toolName: string, input: unknown) => {
emitDelegationEvent({
type: 'tool-input-start',
toolCallId,
toolName,
input: sanitizeForBroadcast(input),
});
};
const emitToolCompletion = (
toolCallId: string,
toolName: string,
rawResult: unknown,
summary: string,
status: 'complete' | 'error' = 'complete',
errorMessage?: string
) => {
emitDelegationEvent({
type: 'tool-output-available',
toolCallId,
toolName,
result: sanitizeForBroadcast(rawResult),
summary,
status,
error: errorMessage,
});
};
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'),
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(`🔄 [WiseRAHExecutor] Iteration ${i + 1}/${maxIterations}`);
// Touch delegation every iteration to prevent cleanup from killing it
AgentDelegationService.touchDelegation(sessionId);
const streamResult = await streamText({
model: openaiProvider('gpt-5'),
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(`📊 [WiseRAHExecutor] Step ${i + 1} finishReason:`, response.finishReason);
// Stream text response to delegation chat
if (response.text && response.text.trim()) {
emitDelegationEvent({
type: 'text-delta',
delta: response.text,
});
}
if (response.finishReason !== 'tool-calls') {
finalText = response.text;
console.log('✅ [WiseRAHExecutor] Got final text');
break;
}
const toolCalls = response.toolCalls || [];
console.log(`🔧 [WiseRAHExecutor] Executing ${toolCalls.length} tool calls`);
// Broadcast new assistant message for next iteration
if (toolCalls.length > 0) {
emitDelegationEvent({ type: 'assistant-message' });
}
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;
}> = [];
let executedTool = false;
for (const call of toolCalls) {
let callInputRaw = (call as any).input ?? (call as any).args;
// Append logic now handled in updateNode tool itself (lines 27-44)
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);
if (!hasPlan && call.toolName !== 'think') {
const warning = 'Planning required: use the think tool to outline a numbered plan (purpose, thoughts, next action) before other tools.';
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);
if (!planReminderAdded) {
planReminderAdded = true;
messages.push({
role: 'user',
content: 'Before calling other tools, use the think tool to draft a numbered plan and specify your next action.',
});
}
continue;
}
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');
if (call.toolName === 'delegateToMiniRAH' && cached.summary) {
if (!workerSummarySet.has(cached.summary)) {
workerSummarySet.add(cached.summary);
workerSummaries.push(`[reused] ${cached.summary}`);
}
}
continue;
}
const tool = wrappedTools[call.toolName];
if (!tool) {
const warning = `Tool ${call.toolName} is not available to wise ra-h.`;
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');
if (call.toolName === 'think') {
hasPlan = true;
lastPlanSummary = summary;
if (allowWrites) {
planIncludesDelegation = /delegate\b|delegateToMiniRAH|mini ra-h/i.test(summary);
} else {
planIncludesDelegation = false;
}
planRevisionNoticeSent = false;
delegationNudgeSent = false;
iterationsSincePlan = 0;
} else {
seenToolResults.set(signature, { output, summary });
if (call.toolName === 'delegateToMiniRAH' && summary && !workerSummarySet.has(summary)) {
workerSummarySet.add(summary);
workerSummaries.push(summary);
totalDelegations += 1;
if (/Created edge/i.test(summary) || /Edge created/i.test(summary)) {
didCreateEdge = true;
}
if (/Updated node/i.test(summary) || /Appended/i.test(summary) || /content updated/i.test(summary)) {
didUpdateNode = true;
}
}
if (call.toolName === 'webSearch') {
const query = ensureString(signatureInput?.query ?? callInputRaw?.query);
if (query) {
uniqueWebQueries.add(query);
}
}
if (call.toolName === 'searchContentEmbeddings') {
const query = ensureString(signatureInput?.query ?? callInputRaw?.query);
if (query) {
uniqueEmbeddingQueries.add(query);
}
}
}
executedTool = true;
} 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 (hasPlan) {
iterationsSincePlan += 1;
// Legacy delegation nudges removed - wise-rah completes workflows independently
}
const enoughMaterial = hasPlan && (allowWrites ? workerSummaries.length >= 2 : executedTool);
const tooManyDelegations = allowWrites && totalDelegations >= maxDelegationsAllowed && maxDelegationsAllowed > 0;
const webSearchCapReached = uniqueWebQueries.size >= maxDistinctWebSearches;
const embeddingCapReached = uniqueEmbeddingQueries.size >= maxDistinctEmbeddingSearches;
if (allowWrites && workflowKey === 'integrate' && totalDelegations === 0) {
iterationsWithoutDelegation += 1;
if (!delegationNudgeSent && iterationsWithoutDelegation >= 4) {
delegationNudgeSent = true;
const targetInstruction = workflowNodeId
? `Focus on node [NODE:${workflowNodeId}]. Delegate to mini ra-h now to (a) append integration insights to its content and (b) create edges to the highest-value related nodes you identified.`
: 'Delegate to mini ra-h now to (a) append integration insights to the focused node and (b) create edges to the highest-value related nodes you identified.';
messages.push({
role: 'user',
content: `${targetInstruction} Do not continue researching until those delegations are complete.`,
});
continue;
}
} else {
iterationsWithoutDelegation = 0;
}
if (allowWrites && workflowKey === 'integrate' && didCreateEdge && !didUpdateNode) {
messages.push({
role: 'user',
content: 'Edges are in place. Delegate to mini ra-h to append the Integration Insights section to the focused node before moving forward.',
});
continue;
}
const minIterationsBeforeSummary = allowWrites ? 2 : 1;
if (!finalSummaryRequested && (tooManyDelegations || webSearchCapReached || embeddingCapReached || (enoughMaterial && executedTool && i >= minIterationsBeforeSummary))) {
if (allowWrites && workflowKey === 'integrate' && !didUpdateNode) {
messages.push({
role: 'user',
content: 'Before summarizing, delegate to mini ra-h to append the Integration Insights content to the focused node.',
});
continue;
}
if (allowWrites && totalDelegations === 0) {
messages.push({
role: 'user',
content: 'You have not delegated any execution yet. Identify the concrete write actions required and call delegateToMiniRAH to perform them before summarising.',
});
continue;
}
let instruction = 'You have enough evidence from the workers. Provide the final Task/Actions/Result/Nodes/Follow-up summary now without further tool calls.';
if (tooManyDelegations) {
instruction = `You have already delegated the maximum allowed (${maxDelegationsAllowed}). Synthesize the findings now using the Task/Actions/Result/Nodes/Follow-up format. Do not call additional tools.`;
} else if (webSearchCapReached) {
instruction = `You have already issued about ${maxDistinctWebSearches} distinct web searches. Consolidate what you found into the final Task/Actions/Result/Nodes/Follow-up summary now—no additional tool calls.`;
} else if (embeddingCapReached) {
instruction = `Embedding searches have covered the knowledge base (limit ${maxDistinctEmbeddingSearches}). Switch to producing the Task/Actions/Result/Nodes/Follow-up summary—do not call further tools.`;
}
finalText = await requestFinalSummary(instruction);
finalSummaryRequested = true;
break;
}
}
if (!finalText) {
if (allowWrites && totalDelegations === 0) {
throw new Error('Wise ra-h attempted to summarize without delegating any execution to mini ra-h.');
}
console.warn('⚠️ [WiseRAHExecutor] Max iterations hit with no summary. Requesting final response without tools.');
finalText = await requestFinalSummary('You have gathered everything needed. Provide the final Task/Actions/Result/Nodes/Follow-up summary now. Do not call any tools.');
console.log('✅ [WiseRAHExecutor] Final summary obtained after tool cutoff.');
}
const usage = totalUsage;
let summary = typeof finalText === 'string' ? finalText.trim() : '';
if (summary.length > 2000) {
console.log('⚠️ [WiseRAHExecutor] 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('📄 [WiseRAHExecutor] Summary after trim:', summary);
console.log('📏 [WiseRAHExecutor] Summary length:', summary.length);
if (!summary) {
emitDelegationEvent({
type: 'assistant-message',
});
emitDelegationEvent({
type: 'text-delta',
delta: 'Wise ra-h attempted to summarise but the response was empty. Check tool logs above for context.',
});
throw new Error('Wise ra-h returned empty summary');
}
console.log('[WiseRAHExecutor] 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',
});
const usageData: UsageData = {
inputTokens,
outputTokens,
totalTokens,
estimatedCostUsd: costResult.totalCostUsd,
modelUsed: 'gpt-5',
provider: 'openai',
toolsUsed: toolsUsedInSession.length > 0 ? toolsUsedInSession : undefined,
toolCallsCount: toolsUsedInSession.length > 0 ? toolsUsedInSession.length : undefined,
traceId,
parentChatId,
workflowKey,
workflowNodeId,
};
const delegation = AgentDelegationService.getDelegation(sessionId);
const delegationId = delegation?.id;
await ChatLoggingMiddleware.logChatInteraction(
task,
summary,
{
helperName: 'wise-rah',
agentType: 'planner',
delegationId: delegationId ?? null,
sessionId,
usageData,
traceId,
parentChatId,
workflowKey,
workflowNodeId,
systemMessage: WISE_RAH_SYSTEM_PROMPT,
backendUsage: [],
},
[]
);
console.log(`💰 [WiseRAHExecutor] Cost: $${costResult.totalCostUsd.toFixed(6)} (${totalTokens} tokens)`);
}
console.log('✅ [WiseRAHExecutor] Completing delegation with summary');
return AgentDelegationService.completeDelegation(sessionId, summary);
} catch (error) {
console.error('❌ [WiseRAHExecutor] Error during execution:', error);
console.error('❌ [WiseRAHExecutor] Error stack:', error instanceof Error ? error.stack : 'No stack');
const message = error instanceof Error ? error.message : 'Unknown delegation error';
// Broadcast error to delegation stream
delegationStreamBroadcaster.broadcast(sessionId, {
type: 'assistant-message',
});
delegationStreamBroadcaster.broadcast(sessionId, {
type: 'text-delta',
delta: `Wise ra-h failed: ${message}`,
});
AgentDelegationService.completeDelegation(sessionId, `Wise ra-h failed: ${message}`, 'failed');
throw error;
}
}
}
+246
View File
@@ -0,0 +1,246 @@
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;
}
}
+313
View File
@@ -0,0 +1,313 @@
import { getSQLiteClient } from '@/services/database/sqlite-client';
import { CostReport, TraceCostSummary, CacheEffectiveness } from '@/types/analytics';
export class CostAnalytics {
static getCostsByDateRange(startDate: string, endDate: string): CostReport {
const db = getSQLiteClient();
const chats = db.prepare(`
SELECT
id,
helper_name,
metadata,
created_at
FROM chats
WHERE created_at >= ? AND created_at <= ?
`).all(startDate, endDate) as Array<{
id: number;
helper_name: string;
metadata: string;
created_at: string;
}>;
let totalCostUsd = 0;
let totalTokens = 0;
let inputTokens = 0;
let outputTokens = 0;
let cacheReadTokens = 0;
let cacheWriteTokens = 0;
let cacheHitCount = 0;
let cacheSavingsUsd = 0;
const costByAgent: Record<string, { costUsd: number; chats: number; tokens: number }> = {};
const costByModel: Record<string, { costUsd: number; chats: number; tokens: number }> = {};
for (const chat of chats) {
let metadata: any = {};
try {
metadata = JSON.parse(chat.metadata || '{}');
} catch (e) {
continue;
}
const costUsd = metadata.estimated_cost_usd || 0;
const tokens = metadata.total_tokens || 0;
const inputToks = metadata.input_tokens || 0;
const outputToks = metadata.output_tokens || 0;
const cacheRead = metadata.cache_read_tokens || 0;
const cacheWrite = metadata.cache_write_tokens || 0;
const cacheHit = metadata.cache_hit || false;
const model = metadata.model_used || 'unknown';
totalCostUsd += costUsd;
totalTokens += tokens;
inputTokens += inputToks;
outputTokens += outputToks;
cacheReadTokens += cacheRead;
cacheWriteTokens += cacheWrite;
if (cacheHit) cacheHitCount++;
if (metadata.cache_savings_pct && cacheHit) {
const fullCost = costUsd / (1 - (metadata.cache_savings_pct / 100));
cacheSavingsUsd += (fullCost - costUsd);
}
if (!costByAgent[chat.helper_name]) {
costByAgent[chat.helper_name] = { costUsd: 0, chats: 0, tokens: 0 };
}
costByAgent[chat.helper_name].costUsd += costUsd;
costByAgent[chat.helper_name].chats += 1;
costByAgent[chat.helper_name].tokens += tokens;
if (!costByModel[model]) {
costByModel[model] = { costUsd: 0, chats: 0, tokens: 0 };
}
costByModel[model].costUsd += costUsd;
costByModel[model].chats += 1;
costByModel[model].tokens += tokens;
}
const cacheRequests = chats.filter(c => {
try {
const meta = JSON.parse(c.metadata || '{}');
return meta.provider === 'anthropic';
} catch {
return false;
}
}).length;
return {
periodStart: startDate,
periodEnd: endDate,
totalCostUsd: parseFloat(totalCostUsd.toFixed(6)),
totalChats: chats.length,
totalTokens,
inputTokens,
outputTokens,
cacheReadTokens,
cacheWriteTokens,
cacheHitRate: cacheRequests > 0 ? cacheHitCount / cacheRequests : 0,
cacheSavingsUsd: parseFloat(cacheSavingsUsd.toFixed(6)),
avgCostPerChat: chats.length > 0 ? parseFloat((totalCostUsd / chats.length).toFixed(6)) : 0,
avgTokensPerChat: chats.length > 0 ? Math.round(totalTokens / chats.length) : 0,
costByAgent,
costByModel,
};
}
static getCostsByAgent(agentName: string, startDate?: string, endDate?: string): CostReport {
const db = getSQLiteClient();
let query = `
SELECT
id,
helper_name,
metadata,
created_at
FROM chats
WHERE helper_name = ?
`;
const params: any[] = [agentName];
if (startDate && endDate) {
query += ` AND created_at >= ? AND created_at <= ?`;
params.push(startDate, endDate);
}
const chats = db.prepare(query).all(...params) as Array<{
id: number;
helper_name: string;
metadata: string;
created_at: string;
}>;
if (chats.length === 0) {
return {
periodStart: startDate || '',
periodEnd: endDate || '',
totalCostUsd: 0,
totalChats: 0,
totalTokens: 0,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
cacheHitRate: 0,
cacheSavingsUsd: 0,
avgCostPerChat: 0,
avgTokensPerChat: 0,
costByAgent: {},
costByModel: {},
};
}
const start = startDate || chats[chats.length - 1].created_at;
const end = endDate || chats[0].created_at;
return this.getCostsByDateRange(start, end);
}
static getCostsByTrace(traceId: string): TraceCostSummary {
const db = getSQLiteClient();
const chats = db.prepare(`
SELECT
id,
helper_name,
agent_type,
metadata,
created_at
FROM chats
WHERE json_extract(metadata, '$.trace_id') = ?
ORDER BY created_at ASC
`).all(traceId) as Array<{
id: number;
helper_name: string;
agent_type: string;
metadata: string;
created_at: string;
}>;
let totalCostUsd = 0;
let orchestratorCost = 0;
let executorCost = 0;
let plannerCost = 0;
let totalTokens = 0;
const interactions: TraceCostSummary['interactions'] = [];
for (const chat of chats) {
let metadata: any = {};
try {
metadata = JSON.parse(chat.metadata || '{}');
} catch (e) {
continue;
}
const costUsd = metadata.estimated_cost_usd || 0;
const tokens = metadata.total_tokens || 0;
totalCostUsd += costUsd;
totalTokens += tokens;
if (chat.agent_type === 'orchestrator') {
orchestratorCost += costUsd;
} else if (chat.agent_type === 'executor') {
executorCost += costUsd;
} else if (chat.agent_type === 'planner') {
plannerCost += costUsd;
}
interactions.push({
chatId: chat.id,
agentName: chat.helper_name,
costUsd: parseFloat(costUsd.toFixed(6)),
tokens,
createdAt: chat.created_at,
});
}
return {
traceId,
totalCostUsd: parseFloat(totalCostUsd.toFixed(6)),
chatCount: chats.length,
orchestratorCost: parseFloat(orchestratorCost.toFixed(6)),
executorCost: parseFloat(executorCost.toFixed(6)),
plannerCost: parseFloat(plannerCost.toFixed(6)),
totalTokens,
interactions,
};
}
static getCacheEffectiveness(startDate?: string, endDate?: string): CacheEffectiveness {
const db = getSQLiteClient();
let query = `
SELECT
metadata
FROM chats
WHERE json_extract(metadata, '$.provider') = 'anthropic'
`;
const params: any[] = [];
if (startDate && endDate) {
query += ` AND created_at >= ? AND created_at <= ?`;
params.push(startDate, endDate);
}
const chats = db.prepare(query).all(...params) as Array<{ metadata: string }>;
let totalRequests = 0;
let cacheHits = 0;
let cacheMisses = 0;
let totalCacheSavingsUsd = 0;
let totalTokensSaved = 0;
for (const chat of chats) {
let metadata: any = {};
try {
metadata = JSON.parse(chat.metadata || '{}');
} catch (e) {
continue;
}
totalRequests++;
if (metadata.cache_hit) {
cacheHits++;
const cacheReadTokens = metadata.cache_read_tokens || 0;
totalTokensSaved += cacheReadTokens;
if (metadata.cache_savings_pct && metadata.estimated_cost_usd) {
const fullCost = metadata.estimated_cost_usd / (1 - (metadata.cache_savings_pct / 100));
totalCacheSavingsUsd += (fullCost - metadata.estimated_cost_usd);
}
} else {
cacheMisses++;
}
}
return {
totalRequests,
cacheHits,
cacheMisses,
hitRate: totalRequests > 0 ? parseFloat((cacheHits / totalRequests).toFixed(4)) : 0,
totalCacheSavingsUsd: parseFloat(totalCacheSavingsUsd.toFixed(6)),
avgSavingsPerHit: cacheHits > 0 ? parseFloat((totalCacheSavingsUsd / cacheHits).toFixed(6)) : 0,
totalTokensSaved,
};
}
static getDailyBreakdown(days: number = 7): Array<{ date: string; cost: number; chats: number; tokens: number }> {
const db = getSQLiteClient();
const result = db.prepare(`
SELECT
DATE(created_at) as date,
SUM(COALESCE(json_extract(metadata, '$.estimated_cost_usd'), 0)) as cost,
COUNT(*) as chats,
SUM(COALESCE(json_extract(metadata, '$.total_tokens'), 0)) as tokens
FROM chats
WHERE created_at >= DATE('now', '-' || ? || ' days')
GROUP BY DATE(created_at)
ORDER BY date DESC
`).all(days) as Array<{ date: string; cost: number; chats: number; tokens: number }>;
return result.map(row => ({
date: row.date,
cost: parseFloat(Number(row.cost).toFixed(6)),
chats: Number(row.chats),
tokens: Number(row.tokens),
}));
}
}
+116
View File
@@ -0,0 +1,116 @@
import { ModelPricing } from '@/types/analytics';
export const MODEL_PRICING: Record<string, ModelPricing> = {
'claude-sonnet-4-5-20250929': {
provider: 'anthropic',
inputPer1M: 3.00,
outputPer1M: 15.00,
cacheWritePer1M: 3.75,
cacheReadPer1M: 0.30,
},
'claude-3-5-sonnet-20241022': {
provider: 'anthropic',
inputPer1M: 3.00,
outputPer1M: 15.00,
cacheWritePer1M: 3.75,
cacheReadPer1M: 0.30,
},
'gpt-4o-mini': {
provider: 'openai',
inputPer1M: 0.15,
outputPer1M: 0.60,
},
'gpt-4o-mini-2024-07-18': {
provider: 'openai',
inputPer1M: 0.15,
outputPer1M: 0.60,
},
'gpt-5o-mini': {
provider: 'openai',
inputPer1M: 0.25,
outputPer1M: 2.00,
},
'gpt-5-mini': {
provider: 'openai',
inputPer1M: 0.25,
outputPer1M: 2.00,
},
'gpt-5': {
provider: 'openai',
inputPer1M: 1.25,
outputPer1M: 10.00,
},
};
export interface CostCalculationInput {
inputTokens: number;
outputTokens: number;
cacheWriteTokens?: number;
cacheReadTokens?: number;
modelId: string;
}
export interface CostCalculationResult {
totalCostUsd: number;
inputCostUsd: number;
outputCostUsd: number;
cacheWriteCostUsd: number;
cacheReadCostUsd: number;
cacheSavingsUsd: number;
totalTokens: number;
}
export function calculateCost(input: CostCalculationInput): CostCalculationResult {
const pricing = MODEL_PRICING[input.modelId];
if (!pricing) {
console.warn(`[Pricing] Unknown model: ${input.modelId}, using default pricing`);
return {
totalCostUsd: 0,
inputCostUsd: 0,
outputCostUsd: 0,
cacheWriteCostUsd: 0,
cacheReadCostUsd: 0,
cacheSavingsUsd: 0,
totalTokens: input.inputTokens + input.outputTokens + (input.cacheWriteTokens || 0) + (input.cacheReadTokens || 0),
};
}
const inputCostUsd = (input.inputTokens / 1_000_000) * pricing.inputPer1M;
const outputCostUsd = (input.outputTokens / 1_000_000) * pricing.outputPer1M;
let cacheWriteCostUsd = 0;
let cacheReadCostUsd = 0;
let cacheSavingsUsd = 0;
if (pricing.cacheWritePer1M && input.cacheWriteTokens) {
cacheWriteCostUsd = (input.cacheWriteTokens / 1_000_000) * pricing.cacheWritePer1M;
}
if (pricing.cacheReadPer1M && input.cacheReadTokens) {
cacheReadCostUsd = (input.cacheReadTokens / 1_000_000) * pricing.cacheReadPer1M;
const regularCostForCachedTokens = (input.cacheReadTokens / 1_000_000) * pricing.inputPer1M;
cacheSavingsUsd = regularCostForCachedTokens - cacheReadCostUsd;
}
const totalCostUsd = inputCostUsd + outputCostUsd + cacheWriteCostUsd + cacheReadCostUsd;
const totalTokens = input.inputTokens + input.outputTokens + (input.cacheWriteTokens || 0) + (input.cacheReadTokens || 0);
return {
totalCostUsd: parseFloat(totalCostUsd.toFixed(6)),
inputCostUsd: parseFloat(inputCostUsd.toFixed(6)),
outputCostUsd: parseFloat(outputCostUsd.toFixed(6)),
cacheWriteCostUsd: parseFloat(cacheWriteCostUsd.toFixed(6)),
cacheReadCostUsd: parseFloat(cacheReadCostUsd.toFixed(6)),
cacheSavingsUsd: parseFloat(cacheSavingsUsd.toFixed(6)),
totalTokens,
};
}
export function getModelPricing(modelId: string): ModelPricing | null {
return MODEL_PRICING[modelId] || null;
}
export function getSupportedModels(): string[] {
return Object.keys(MODEL_PRICING);
}
+246
View File
@@ -0,0 +1,246 @@
import { getSQLiteClient } from '@/services/database/sqlite-client';
import { UsageData } from '@/types/analytics';
import { RequestContext } from '@/services/context/requestContext';
interface ChatLogEntry {
chat_type: string;
user_message: string;
assistant_message: string;
thread_id: string;
focused_node_id: number | null;
helper_name: string;
agent_type: 'orchestrator' | 'executor' | 'planner';
delegation_id: number | null;
metadata: any;
}
interface StreamMetadata {
helperName: string;
openTabs?: any[];
activeTabId?: number | null;
currentView?: 'nodes' | 'memory';
sessionId?: string;
agentType?: 'orchestrator' | 'executor' | 'planner';
delegationId?: number | null;
usageData?: UsageData;
traceId?: string;
parentChatId?: number;
systemMessage?: string;
workflowKey?: string;
workflowNodeId?: number;
mode?: 'easy' | 'hard';
toolCallsData?: any[];
backendUsage?: Array<{
provider: string;
headers: Record<string, string>;
}>;
}
export class ChatLoggingMiddleware {
private static generateThreadId(helperName: string, metadata: StreamMetadata): string {
const { activeTabId = null, currentView, sessionId } = metadata;
const timestamp = Date.now();
const session = sessionId || `session_${timestamp}`;
if (activeTabId) {
return `${helperName}-node-${activeTabId}-${session}`;
}
return `${helperName}-general-${session}`;
}
private static extractUserMessage(messages: any[]): string | null {
const userMessages = messages.filter(m => m.role === 'user');
const lastUserMessage = userMessages[userMessages.length - 1];
if (!lastUserMessage) return null;
// Handle different message formats (AI SDK v5)
if (typeof lastUserMessage.content === 'string') {
return lastUserMessage.content;
}
// Handle parts-based messages (from frontend)
if (Array.isArray(lastUserMessage.parts)) {
const textParts = lastUserMessage.parts
.filter((part: any) => part.type === 'text')
.map((part: any) => part.text);
return textParts.join(' ');
}
// Handle content as object or other formats
if (lastUserMessage.content && typeof lastUserMessage.content === 'object') {
return JSON.stringify(lastUserMessage.content);
}
return lastUserMessage.content || null;
}
static async logChatInteraction(
userMessage: string,
assistantMessage: string,
metadata: StreamMetadata,
messages: any[] = []
): Promise<void> {
try {
const threadId = this.generateThreadId(metadata.helperName, metadata);
const createdAt = new Date().toISOString();
const chatEntry: ChatLogEntry = {
chat_type: 'helper',
user_message: userMessage,
assistant_message: assistantMessage,
thread_id: threadId,
focused_node_id: metadata.activeTabId ?? null,
helper_name: metadata.helperName,
agent_type: metadata.agentType || 'orchestrator',
delegation_id: metadata.delegationId ?? null,
metadata: {
timestamp: new Date().toISOString(),
session_id: metadata.sessionId,
current_view: metadata.currentView || 'nodes',
open_tab_count: metadata.openTabs?.length || 0,
has_focused_node: !!metadata.activeTabId,
message_count: messages.length,
...(metadata.mode && { mode: metadata.mode }),
// System message
...(metadata.systemMessage && { system_message: metadata.systemMessage }),
// Enhanced usage data
...(metadata.usageData && {
input_tokens: metadata.usageData.inputTokens,
output_tokens: metadata.usageData.outputTokens,
total_tokens: metadata.usageData.totalTokens,
cache_write_tokens: metadata.usageData.cacheWriteTokens,
cache_read_tokens: metadata.usageData.cacheReadTokens,
cache_hit: metadata.usageData.cacheHit,
cache_savings_pct: metadata.usageData.cacheSavingsPct,
estimated_cost_usd: metadata.usageData.estimatedCostUsd,
model_used: metadata.usageData.modelUsed,
provider: metadata.usageData.provider,
tools_used: metadata.usageData.toolsUsed,
tool_calls_count: metadata.usageData.toolCallsCount,
capsule_version: metadata.usageData.capsuleVersion,
context_sources_used: metadata.usageData.contextSourcesUsed,
validation_status: metadata.usageData.validationStatus,
validation_message: metadata.usageData.validationMessage,
fallback_action: metadata.usageData.fallbackAction,
}),
// Tool calls data
...(metadata.toolCallsData && metadata.toolCallsData.length > 0 && {
tool_calls: metadata.toolCallsData
}),
// Trace grouping
...(metadata.traceId && { trace_id: metadata.traceId }),
...(metadata.parentChatId && { parent_chat_id: metadata.parentChatId }),
// Workflow metadata
...(metadata.workflowKey && {
workflow_key: metadata.workflowKey,
workflow_node_id: metadata.workflowNodeId,
is_workflow: true,
}),
// Backend usage (for Supabase sync correlation)
...(metadata.backendUsage && metadata.backendUsage.length > 0 && {
backend_usage: metadata.backendUsage,
}),
}
};
const sqlite = getSQLiteClient();
const result = sqlite.prepare(`
INSERT INTO chats (chat_type, user_message, assistant_message, thread_id, focused_node_id, helper_name, agent_type, delegation_id, created_at, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
chatEntry.chat_type,
chatEntry.user_message,
chatEntry.assistant_message,
chatEntry.thread_id,
chatEntry.focused_node_id,
chatEntry.helper_name,
chatEntry.agent_type,
chatEntry.delegation_id,
createdAt,
JSON.stringify(chatEntry.metadata)
);
console.log(`✅ Chat logged for ${metadata.helperName}, ID: ${result.lastInsertRowid}`);
const lastInsertedChatId = Number(result.lastInsertRowid);
if (metadata.agentType === 'orchestrator' && (metadata.helperName === 'ra-h' || metadata.helperName === 'ra-h-easy')) {
RequestContext.set({
traceId: metadata.traceId,
parentChatId: lastInsertedChatId
});
}
} catch (error) {
console.error('❌ Chat logging error:', error);
}
}
static createLoggingHandlers(metadata: StreamMetadata, messages: any[]) {
let assistantResponse = '';
const userMessage = this.extractUserMessage(messages);
return {
onFinish: async (result: any) => {
const { text, toolCalls, steps } = result;
// Log if we have a user message and either text OR tool activity
const hasActivity = text || toolCalls?.length > 0 || steps?.length > 0;
if (userMessage && hasActivity) {
// Capture tool calls if present
const toolCallsData = toolCalls && toolCalls.length > 0 ? toolCalls.map((tc: any) => ({
toolName: tc.toolName,
args: tc.args,
result: typeof tc.result === 'object' ? tc.result : { value: tc.result }
})) : undefined;
if (toolCallsData) {
console.log(`🔧 Captured ${toolCallsData.length} tool calls for logging`);
}
const enhancedMetadata = {
...metadata,
toolCallsData
};
await this.logChatInteraction(
userMessage,
text || '[Tool calls only - no text response]',
enhancedMetadata,
messages
);
} else if (userMessage && !hasActivity) {
console.warn(`⚠️ Skipping chat log - no text or tool activity for user message: ${userMessage.substring(0, 50)}...`);
}
},
onChunk: ({ chunk }: { chunk: any }) => {
if (chunk.type === 'text-delta' && chunk.textDelta) {
assistantResponse += chunk.textDelta;
}
}
};
}
}
export function withChatLogging(
streamConfig: any,
metadata: StreamMetadata,
messages: any[]
) {
const handlers = ChatLoggingMiddleware.createLoggingHandlers(metadata, messages);
const originalOnFinish = streamConfig.onFinish;
return {
...streamConfig,
onFinish: async (result: any) => {
// Call original onFinish first (for cache stats)
if (originalOnFinish) {
await originalOnFinish(result);
}
// Then call logging handler
await handlers.onFinish(result);
},
onChunk: handlers.onChunk
};
}
+72
View File
@@ -0,0 +1,72 @@
import { getSQLiteClient } from '@/services/database/sqlite-client';
import { getAutoContextSettings } from '@/services/settings/autoContextSettings';
interface AutoContextRow {
id: number;
title: string | null;
updated_at: string;
edge_count: number | null;
}
export interface AutoContextSummary {
id: number;
title: string;
edgeCount: number;
updatedAt: string;
}
function fetchAutoContextRows(limit: number): AutoContextSummary[] {
const db = getSQLiteClient();
const rows = db
.query<AutoContextRow>(
`
SELECT n.id,
n.title,
n.updated_at,
COUNT(DISTINCT e.id) AS edge_count
FROM nodes n
LEFT JOIN edges e
ON (e.from_node_id = n.id OR e.to_node_id = n.id)
WHERE n.type IS NULL OR n.type != 'memory'
GROUP BY n.id
ORDER BY edge_count DESC, n.updated_at DESC
LIMIT ?
`,
[limit]
)
.rows;
return rows.map((row) => ({
id: row.id,
title: row.title || 'Untitled node',
updatedAt: row.updated_at,
edgeCount: Number(row.edge_count ?? 0),
}));
}
export function getAutoContextSummaries(limit = 10): AutoContextSummary[] {
const settings = getAutoContextSettings();
if (!settings.autoContextEnabled) {
return [];
}
return fetchAutoContextRows(limit);
}
export function buildAutoContextBlock(limit = 10): string | null {
const summaries = getAutoContextSummaries(limit);
if (summaries.length === 0) {
return null;
}
const lines: string[] = [
'=== BACKGROUND CONTEXT ===',
'Top 10 most-connected nodes (important knowledge hubs). Use queryNodes/getNodesById if relevant.',
'',
];
for (const summary of summaries) {
lines.push(`[NODE:${summary.id}:"${summary.title}"] (edges: ${summary.edgeCount})`);
}
return lines.join('\n');
}
+37
View File
@@ -0,0 +1,37 @@
import type { Node } from '@/types/database';
interface RequestContext {
traceId?: string;
parentChatId?: number;
workflowKey?: string;
workflowNodeId?: number;
openTabs?: Node[];
activeTabId?: number | null;
mode?: 'easy' | 'hard';
apiKeys?: {
openai?: string;
anthropic?: string;
};
}
let currentContext: RequestContext = {};
export const RequestContext = {
set(context: Partial<RequestContext>) {
Object.entries(context).forEach(([key, value]) => {
if (value === undefined) {
delete (currentContext as Record<string, unknown>)[key];
} else {
(currentContext as Record<string, unknown>)[key] = value;
}
});
},
get(): RequestContext {
return currentContext;
},
clear() {
currentContext = {};
},
};
+331
View File
@@ -0,0 +1,331 @@
import { getSQLiteClient } from './sqlite-client';
import { Chunk, ChunkData } from '@/types/database';
export class ChunkService {
async getChunksByNodeId(nodeId: number): Promise<Chunk[]> {
const sqlite = getSQLiteClient();
const result = sqlite.query<Chunk>('SELECT * FROM chunks WHERE node_id = ? ORDER BY chunk_idx ASC', [nodeId]);
return result.rows;
}
async getChunkById(id: number): Promise<Chunk | null> {
const sqlite = getSQLiteClient();
const result = sqlite.query<Chunk>('SELECT * FROM chunks WHERE id = ?', [id]);
return result.rows[0] || null;
}
async createChunk(chunkData: ChunkData): Promise<Chunk> {
return this.createChunkSQLite(chunkData);
}
// PostgreSQL path removed in SQLite-only consolidation
private async createChunkSQLite(chunkData: ChunkData): Promise<Chunk> {
const now = new Date().toISOString();
const sqlite = getSQLiteClient();
const result = sqlite.prepare(`
INSERT INTO chunks (node_id, chunk_idx, text, embedding, embedding_type, metadata, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
chunkData.node_id,
chunkData.chunk_idx || null,
chunkData.text,
chunkData.embedding || null,
chunkData.embedding_type,
chunkData.metadata ? JSON.stringify(chunkData.metadata) : null,
now
);
const chunkId = Number(result.lastInsertRowid);
const createdChunk = await this.getChunkById(chunkId);
if (!createdChunk) {
throw new Error('Failed to create chunk');
}
return createdChunk;
}
async createChunks(chunksData: ChunkData[]): Promise<Chunk[]> {
if (chunksData.length === 0) {
return [];
}
return this.createChunksSQLite(chunksData);
}
// PostgreSQL path removed in SQLite-only consolidation
private async createChunksSQLite(chunksData: ChunkData[]): Promise<Chunk[]> {
const sqlite = getSQLiteClient();
const now = new Date().toISOString();
const createdChunks: Chunk[] = [];
// Use transaction for bulk insert
sqlite.transaction(() => {
const stmt = sqlite.prepare(`
INSERT INTO chunks (node_id, chunk_idx, text, embedding, embedding_type, metadata, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
for (const chunk of chunksData) {
stmt.run(
chunk.node_id,
chunk.chunk_idx || null,
chunk.text,
chunk.embedding || null,
chunk.embedding_type,
chunk.metadata ? JSON.stringify(chunk.metadata) : null,
now
);
}
});
// Get all created chunks by node_id (since we know they were just created)
const nodeIds = [...new Set(chunksData.map(c => c.node_id))];
for (const nodeId of nodeIds) {
const chunks = await this.getChunksByNodeId(nodeId);
createdChunks.push(...chunks.filter(c => c.created_at === now));
}
return createdChunks;
}
async updateChunk(id: number, updates: Partial<Chunk>): Promise<Chunk> {
return this.updateChunkSQLite(id, updates);
}
// PostgreSQL path removed in SQLite-only consolidation
private async updateChunkSQLite(id: number, updates: Partial<Chunk>): Promise<Chunk> {
const sqlite = getSQLiteClient();
const updateFields: string[] = [];
const params: any[] = [];
// Build dynamic update query
Object.entries(updates).forEach(([key, value]) => {
if (key !== 'id' && key !== 'created_at' && value !== undefined) {
updateFields.push(`${key} = ?`);
if (key === 'metadata') {
params.push(typeof value === 'object' ? JSON.stringify(value) : value);
} else {
params.push(value);
}
}
});
if (updateFields.length === 0) {
throw new Error('No valid fields to update');
}
params.push(id); // Add ID for WHERE clause
const query = `UPDATE chunks SET ${updateFields.join(', ')} WHERE id = ?`;
const result = sqlite.query(query, params);
if (result.changes === 0) {
throw new Error(`Chunk with ID ${id} not found`);
}
const updatedChunk = await this.getChunkById(id);
if (!updatedChunk) {
throw new Error(`Failed to retrieve updated chunk with ID ${id}`);
}
return updatedChunk;
}
async deleteChunk(id: number): Promise<void> {
const sqlite = getSQLiteClient();
const result = sqlite.query('DELETE FROM chunks WHERE id = ?', [id]);
if ((result.changes || 0) === 0) {
throw new Error(`Chunk with ID ${id} not found`);
}
}
async deleteChunksByNodeId(nodeId: number): Promise<void> {
const sqlite = getSQLiteClient();
sqlite.query('DELETE FROM chunks WHERE node_id = ?', [nodeId]);
}
async searchChunks(
queryEmbedding: number[],
similarityThreshold = 0.5,
matchCount = 5,
nodeIds?: number[],
fallbackQuery?: string
): Promise<Array<Chunk & { similarity: number }>> {
try {
return await this.searchChunksSQLite(queryEmbedding, similarityThreshold, matchCount, nodeIds);
} catch (error) {
console.warn('Vector search failed, attempting text fallback:', error);
if (fallbackQuery) {
return await this.textSearchFallback(fallbackQuery, matchCount, nodeIds);
}
throw error;
}
}
// PostgreSQL path removed in SQLite-only consolidation
private async searchChunksSQLite(
queryEmbedding: number[],
similarityThreshold = 0.5,
matchCount = 5,
nodeIds?: number[]
): Promise<Array<Chunk & { similarity: number }>> {
const sqlite = getSQLiteClient();
// Step 1: Determine vector search limit - more conservative approach
let vectorLimit = 50; // Start smaller for efficiency
if (nodeIds && nodeIds.length > 0) {
// When searching specific nodes, get exact count and use reasonable multiplier
const candidateCountQuery = `SELECT COUNT(*) as count FROM chunks WHERE node_id IN (${nodeIds.map(() => '?').join(',')})`;
const candidateResult = sqlite.query<{count: number}>(candidateCountQuery, nodeIds);
const candidateCount = Number(candidateResult.rows[0].count);
// Use 2x the candidate count but cap at reasonable limits
vectorLimit = Math.min(Math.max(candidateCount * 2, 50), 1000);
console.log(`🔍 Node-scoped search: ${candidateCount} candidates, using vector limit ${vectorLimit}`);
} else {
// For global search, use adaptive limit based on expected results
vectorLimit = Math.max(matchCount * 10, 50);
}
const startTime = Date.now();
// Step 2: Use CTE-based query to avoid UNION ALL explosion
const vectorString = `[${queryEmbedding.join(',')}]`;
let query = `
WITH vector_results AS (
SELECT chunk_id, distance
FROM vec_chunks
WHERE embedding MATCH ?
ORDER BY distance
LIMIT ?
)
SELECT c.*, (1.0 / (1.0 + vr.distance)) AS similarity
FROM vector_results vr
JOIN chunks c ON c.id = vr.chunk_id
`;
const params: any[] = [vectorString, vectorLimit];
const conditions = [];
// Node ID filter if provided
if (nodeIds && nodeIds.length > 0) {
conditions.push(`c.node_id IN (${nodeIds.map(() => '?').join(',')})`);
params.push(...nodeIds);
}
// Similarity threshold filter
conditions.push(`(1.0 / (1.0 + vr.distance)) >= ?`);
params.push(similarityThreshold);
if (conditions.length > 0) {
query += ` WHERE ${conditions.join(' AND ')}`;
}
query += ` ORDER BY similarity DESC LIMIT ?`;
params.push(matchCount);
const result = sqlite.query<Chunk & { similarity: number }>(query, params);
const searchTime = Date.now() - startTime;
console.log(`📊 Vector search: ${result.rows.length}/${vectorLimit} chunks, threshold=${similarityThreshold}, time=${searchTime}ms`);
if (result.rows.length > 0) {
console.log(`🎯 Top result: chunk ${result.rows[0].id} (similarity: ${result.rows[0].similarity.toFixed(3)})`);
}
return result.rows;
}
async textSearchFallback(
query: string,
matchCount = 5,
nodeIds?: number[]
): Promise<Array<Chunk & { similarity: number }>> {
const sqlite = getSQLiteClient();
const startTime = Date.now();
// Clean query for LIKE search
const cleanQuery = query.trim().toLowerCase();
const searchTerms = cleanQuery.split(/\s+/).filter(term => term.length > 2);
if (searchTerms.length === 0) {
return [];
}
// Build LIKE conditions for each term
const likeConditions = searchTerms.map(() => 'LOWER(text) LIKE ?').join(' AND ');
const likeParams = searchTerms.map(term => `%${term}%`);
let textQuery = `
SELECT *, 0.8 as similarity
FROM chunks
WHERE ${likeConditions}
`;
const params = [...likeParams];
// Add node filter if provided
if (nodeIds && nodeIds.length > 0) {
textQuery += ` AND node_id IN (${nodeIds.map(() => '?').join(',')})`;
params.push(...nodeIds.map(String));
}
textQuery += ` ORDER BY LENGTH(text) ASC LIMIT ?`;
params.push(String(matchCount));
const result = sqlite.query<Chunk & { similarity: number }>(textQuery, params);
const searchTime = Date.now() - startTime;
console.log(`📝 Text fallback: ${result.rows.length} chunks found, time=${searchTime}ms`);
return result.rows;
}
async getChunkCount(): Promise<number> {
const sqlite = getSQLiteClient();
const result = sqlite.query('SELECT COUNT(*) as count FROM chunks');
return Number(result.rows[0].count);
}
async getChunkCountByNodeId(nodeId: number): Promise<number> {
const sqlite = getSQLiteClient();
const result = sqlite.query('SELECT COUNT(*) as count FROM chunks WHERE node_id = ?', [nodeId]);
return Number(result.rows[0].count);
}
async getNodesWithChunks(): Promise<Array<{ node_id: number; chunk_count: number }>> {
const sqlite = getSQLiteClient();
const result = sqlite.query(`
SELECT node_id, COUNT(*) as chunk_count
FROM chunks
GROUP BY node_id
ORDER BY chunk_count DESC
`);
return result.rows.map((row: any) => ({
node_id: Number(row.node_id),
chunk_count: Number(row.chunk_count)
}));
}
async getChunksWithoutEmbeddings(): Promise<Chunk[]> {
// In SQLite, chunk vectors live in vec_chunks; report chunks without corresponding vector rows
const sqlite = getSQLiteClient();
const result = sqlite.query<Chunk>(`
SELECT c.*
FROM chunks c
LEFT JOIN vec_chunks v ON v.chunk_id = c.id
WHERE v.chunk_id IS NULL
ORDER BY c.created_at ASC
`);
return result.rows;
}
}
// Export singleton instance
export const chunkService = new ChunkService();
+202
View File
@@ -0,0 +1,202 @@
import { getSQLiteClient } from './sqlite-client';
import { openai as openaiProvider } from '@ai-sdk/openai';
import { generateText } from 'ai';
export interface Dimension {
name: string;
description: string | null;
is_priority: boolean;
updated_at: string;
}
export interface LockedDimension {
name: string;
description: string | null;
count: number;
}
export class DimensionService {
/**
* Get all locked (priority) dimensions with their descriptions
*/
static async getLockedDimensions(): Promise<LockedDimension[]> {
const sqlite = getSQLiteClient();
const result = sqlite.query(`
WITH dimension_counts AS (
SELECT nd.dimension, COUNT(*) AS count
FROM node_dimensions nd
GROUP BY nd.dimension
)
SELECT
d.name,
d.description,
COALESCE(dc.count, 0) AS count
FROM dimensions d
LEFT JOIN dimension_counts dc ON dc.dimension = d.name
WHERE d.is_priority = 1
ORDER BY d.name ASC
`);
return result.rows.map((row: any) => ({
name: row.name,
description: row.description,
count: Number(row.count)
}));
}
/**
* Automatically assign locked dimensions to a node based on its content
*/
static async assignLockedDimensions(nodeData: {
title: string;
content?: string;
link?: string;
}): Promise<string[]> {
try {
const lockedDimensions = await this.getLockedDimensions();
if (lockedDimensions.length === 0) {
console.log('No locked dimensions available for assignment');
return [];
}
const prompt = this.buildAssignmentPrompt(nodeData, lockedDimensions);
const response = await generateText({
model: openaiProvider('gpt-4o-mini'),
prompt,
maxOutputTokens: 100,
temperature: 0.1, // Low temperature for consistent results
});
const assignedDimensions = this.parseAssignmentResponse(response.text, lockedDimensions);
console.log(`Assigned dimensions for "${nodeData.title}": ${assignedDimensions.join(', ')}`);
return assignedDimensions;
} catch (error) {
console.error('Error assigning dimensions:', error);
return []; // Graceful fallback
}
}
/**
* Update dimension description
*/
static async updateDimensionDescription(name: string, description: string): Promise<void> {
const sqlite = getSQLiteClient();
sqlite.query(`
INSERT INTO dimensions(name, description, is_priority, updated_at)
VALUES (?, ?, 0, CURRENT_TIMESTAMP)
ON CONFLICT(name) DO UPDATE SET
description = ?,
updated_at = CURRENT_TIMESTAMP
`, [name, description, description]);
}
/**
* Get dimension by name with description
*/
static async getDimensionByName(name: string): Promise<Dimension | null> {
const sqlite = getSQLiteClient();
const result = sqlite.query(`
SELECT name, description, is_priority, updated_at
FROM dimensions
WHERE name = ?
`, [name]);
if (result.rows.length === 0) {
return null;
}
const row = result.rows[0] as any;
return {
name: row.name,
description: row.description,
is_priority: Boolean(row.is_priority),
updated_at: row.updated_at
};
}
/**
* Build AI prompt for dimension assignment
*/
private static buildAssignmentPrompt(
nodeData: { title: string; content?: string; link?: string },
lockedDimensions: LockedDimension[]
): string {
const contentPreview = nodeData.content?.slice(0, 500) || '';
const dimensionsList = lockedDimensions
.map(d => `- ${d.name}: ${d.description || `Infer purpose from name: ${d.name}`}`)
.join('\n');
return `Analyze this node content and assign appropriate dimensions from the available locked dimensions.
Node:
Title: ${nodeData.title}
Content: ${contentPreview}${nodeData.content && nodeData.content.length > 500 ? '...' : ''}
URL: ${nodeData.link || 'none'}
Available Locked Dimensions:
${dimensionsList}
Return only the dimension names that best match this content, maximum 3 dimensions.
Respond with just the dimension names, one per line.
If no dimensions are appropriate, respond with "none".
Examples of good responses:
Work
Learning
or
Research
Ideas
Tech
or
none`;
}
/**
* Parse AI response and validate dimension names
*/
private static parseAssignmentResponse(
response: string,
availableDimensions: LockedDimension[]
): string[] {
const lines = response.trim().toLowerCase().split('\n');
const availableNames = availableDimensions.map(d => d.name.toLowerCase());
const validDimensions: string[] = [];
for (const line of lines) {
const dimensionName = line.trim();
if (dimensionName === 'none') {
break;
}
// Find matching dimension (case-insensitive)
const matchedDimension = availableDimensions.find(
d => d.name.toLowerCase() === dimensionName
);
if (matchedDimension && !validDimensions.includes(matchedDimension.name)) {
validDimensions.push(matchedDimension.name);
// Limit to 3 dimensions max
if (validDimensions.length >= 3) {
break;
}
}
}
return validDimensions;
}
}
export const dimensionService = new DimensionService();
+340
View File
@@ -0,0 +1,340 @@
import { getSQLiteClient } from './sqlite-client';
import { Edge, EdgeData, NodeConnection, Node } from '@/types/database';
import { eventBroadcaster } from '../events';
export class EdgeService {
async getEdges(): Promise<Edge[]> {
const sqlite = getSQLiteClient();
const result = sqlite.query<Edge>('SELECT * FROM edges ORDER BY created_at DESC');
return result.rows;
}
async getEdgeById(id: number): Promise<Edge | null> {
const sqlite = getSQLiteClient();
const result = sqlite.query<Edge>('SELECT * FROM edges WHERE id = ?', [id]);
return result.rows[0] || null;
}
async createEdge(edgeData: EdgeData): Promise<Edge> {
return this.createEdgeSQLite(edgeData);
}
// PostgreSQL path removed in SQLite-only consolidation
private async createEdgeSQLite(edgeData: EdgeData): Promise<Edge> {
const now = new Date().toISOString();
const sqlite = getSQLiteClient();
const result = sqlite.prepare(`
INSERT INTO edges (from_node_id, to_node_id, context, source, created_at)
VALUES (?, ?, ?, ?, ?)
`).run(
edgeData.from_node_id,
edgeData.to_node_id,
JSON.stringify(edgeData.context || {}),
edgeData.source,
now
);
const edgeId = Number(result.lastInsertRowid);
const newEdge = await this.getEdgeById(edgeId);
if (!newEdge) {
throw new Error('Failed to create edge');
}
// Broadcast edge creation event
eventBroadcaster.broadcast({
type: 'EDGE_CREATED',
data: {
fromNodeId: newEdge.from_node_id,
toNodeId: newEdge.to_node_id,
edge: newEdge
}
});
return newEdge;
}
async updateEdge(id: number, updates: Partial<Edge>): Promise<Edge> {
return this.updateEdgeSQLite(id, updates);
}
// PostgreSQL path removed in SQLite-only consolidation
private async updateEdgeSQLite(id: number, updates: Partial<Edge>): Promise<Edge> {
const sqlite = getSQLiteClient();
const updateFields: string[] = [];
const params: any[] = [];
// Build dynamic update query
Object.entries(updates).forEach(([key, value]) => {
if (key !== 'id' && key !== 'created_at' && value !== undefined) {
updateFields.push(`${key} = ?`);
if (key === 'context') {
params.push(typeof value === 'object' ? JSON.stringify(value) : value);
} else {
params.push(value);
}
}
});
if (updateFields.length === 0) {
throw new Error('No valid fields to update');
}
params.push(id); // Add ID for WHERE clause
const query = `UPDATE edges SET ${updateFields.join(', ')} WHERE id = ?`;
const result = sqlite.query(query, params);
if (result.changes === 0) {
throw new Error(`Edge with ID ${id} not found`);
}
const updatedEdge = await this.getEdgeById(id);
if (!updatedEdge) {
throw new Error(`Failed to retrieve updated edge with ID ${id}`);
}
return updatedEdge;
}
async deleteEdge(id: number): Promise<void> {
const sqlite = getSQLiteClient();
const result = sqlite.query('DELETE FROM edges WHERE id = ?', [id]);
if ((result.changes || 0) === 0) {
throw new Error(`Edge with ID ${id} not found`);
}
// Broadcast edge deletion event
eventBroadcaster.broadcast({
type: 'EDGE_DELETED',
data: { edgeId: id }
});
}
async deleteEdgesByNodeId(nodeId: number): Promise<void> {
const sqlite = getSQLiteClient();
sqlite.query(
'DELETE FROM edges WHERE from_node_id = ? OR to_node_id = ?',
[nodeId, nodeId]
);
}
async getNodeConnections(nodeId: number): Promise<NodeConnection[]> {
return this.getNodeConnectionsSQLite(nodeId);
}
// PostgreSQL path removed in SQLite-only consolidation
private async getNodeConnectionsSQLite(nodeId: number): Promise<NodeConnection[]> {
const sqlite = getSQLiteClient();
const result = sqlite.query(`
SELECT
e.*,
CASE
WHEN e.from_node_id = ? THEN n_to.id
ELSE n_from.id
END as connected_node_id,
CASE
WHEN e.from_node_id = ? THEN n_to.title
ELSE n_from.title
END as connected_node_title,
CASE
WHEN e.from_node_id = ? THEN n_to.content
ELSE n_from.content
END as connected_node_content,
CASE
WHEN e.from_node_id = ? THEN n_to.link
ELSE n_from.link
END as connected_node_link,
CASE
WHEN e.from_node_id = ? THEN n_to.chunk
ELSE n_from.chunk
END as connected_node_chunk,
CASE
WHEN e.from_node_id = ? THEN n_to.metadata
ELSE n_from.metadata
END as connected_node_metadata,
CASE
WHEN e.from_node_id = ? THEN n_to.created_at
ELSE n_from.created_at
END as connected_node_created_at,
CASE
WHEN e.from_node_id = ? THEN n_to.updated_at
ELSE n_from.updated_at
END as connected_node_updated_at,
CASE
WHEN e.from_node_id = ? THEN (
SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n_to.id
)
ELSE (
SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n_from.id
)
END as connected_node_dimensions_json
FROM edges e
LEFT JOIN nodes n_from ON e.from_node_id = n_from.id
LEFT JOIN nodes n_to ON e.to_node_id = n_to.id
WHERE e.from_node_id = ? OR e.to_node_id = ?
ORDER BY e.created_at DESC
`, [
nodeId,
nodeId,
nodeId,
nodeId,
nodeId,
nodeId,
nodeId,
nodeId,
nodeId,
nodeId,
nodeId
]);
return this.mapNodeConnectionsSQLite(result.rows);
}
private mapNodeConnections(rows: any[]): NodeConnection[] {
return rows.map(row => {
const edge: Edge = {
id: row.id,
from_node_id: row.from_node_id,
to_node_id: row.to_node_id,
context: row.context,
source: row.source,
created_at: row.created_at
};
const connected_node: Node = {
id: row.connected_node_id,
title: row.connected_node_title,
content: row.connected_node_content,
link: row.connected_node_link,
dimensions: row.connected_node_dimensions,
embedding: undefined, // Not needed for display
chunk: row.connected_node_chunk,
metadata: row.connected_node_metadata,
created_at: row.connected_node_created_at,
updated_at: row.connected_node_updated_at
};
return {
id: edge.id,
connected_node,
edge
};
});
}
private mapNodeConnectionsSQLite(rows: any[]): NodeConnection[] {
return rows.map(row => {
let context: any = row.context;
if (typeof row.context === 'string') {
const trimmed = row.context.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
try {
context = JSON.parse(trimmed);
} catch (error) {
console.warn('[edges] Failed to parse JSON context for edge', row.id, error);
context = row.context;
}
}
}
const edge: Edge = {
id: row.id,
from_node_id: row.from_node_id,
to_node_id: row.to_node_id,
context,
source: row.source,
created_at: row.created_at
};
const connected_node: Node = {
id: row.connected_node_id,
title: row.connected_node_title,
content: row.connected_node_content,
link: row.connected_node_link,
dimensions: JSON.parse(row.connected_node_dimensions_json || '[]'),
embedding: undefined, // Not needed for display
chunk: row.connected_node_chunk,
metadata: typeof row.connected_node_metadata === 'string' ? JSON.parse(row.connected_node_metadata) : row.connected_node_metadata,
created_at: row.connected_node_created_at,
updated_at: row.connected_node_updated_at
};
return {
id: edge.id,
connected_node,
edge
};
});
}
async edgeExists(fromId: number, toId: number): Promise<boolean> {
const sqlite = getSQLiteClient();
const result = sqlite.query('SELECT 1 FROM edges WHERE from_node_id = ? AND to_node_id = ?', [fromId, toId]);
return result.rows.length > 0;
}
async getEdgeCount(): Promise<number> {
const sqlite = getSQLiteClient();
const result = sqlite.query('SELECT COUNT(*) as count FROM edges');
return Number(result.rows[0].count);
}
async getMostConnectedNodes(limit = 10): Promise<Array<{ node_id: number; connection_count: number }>> {
const sqlite = getSQLiteClient();
const result = sqlite.query(`
SELECT
node_id,
COUNT(*) as connection_count
FROM (
SELECT from_node_id as node_id FROM edges
UNION ALL
SELECT to_node_id as node_id FROM edges
) combined
GROUP BY node_id
ORDER BY connection_count DESC
LIMIT ?
`, [limit]);
return result.rows.map((row: any) => ({
node_id: Number(row.node_id),
connection_count: Number(row.connection_count)
}));
}
async createBidirectionalEdge(fromId: number, toId: number, options?: {
context?: any;
source?: 'user' | 'ai_similarity' | 'helper_name';
}): Promise<Edge[]> {
const edges: Edge[] = [];
// Create edge from A to B
const forwardEdge = await this.createEdge({
from_node_id: fromId,
to_node_id: toId,
context: options?.context,
source: options?.source || 'ai_similarity'
});
edges.push(forwardEdge);
// Create edge from B to A
const backwardEdge = await this.createEdge({
from_node_id: toId,
to_node_id: fromId,
context: options?.context,
source: options?.source || 'ai_similarity'
});
edges.push(backwardEdge);
return edges;
}
}
// Export singleton instance
export const edgeService = new EdgeService();
+70
View File
@@ -0,0 +1,70 @@
// Service instances
export { nodeService, NodeService } from './nodes';
export { chunkService, ChunkService } from './chunks';
export { edgeService, EdgeService } from './edges';
export { dimensionService, DimensionService } from './dimensionService';
// export { HelperService } from './helpers'; // Removed - migrated to JSON-based service
// Types
export * from '@/types/database';
// Health check utility
export async function checkDatabaseHealth(): Promise<{
connected: boolean;
vectorExtension: boolean;
tablesExist: boolean;
error?: string;
}> {
try {
return checkSQLiteDatabaseHealth();
} catch (error) {
return {
connected: false,
vectorExtension: false,
tablesExist: false,
error: error instanceof Error ? error.message : 'Unknown error'
};
}
}
async function checkSQLiteDatabaseHealth(): Promise<{
connected: boolean;
vectorExtension: boolean;
tablesExist: boolean;
error?: string;
}> {
try {
const { getSQLiteClient } = await import('./sqlite-client');
const sqlite = getSQLiteClient();
const connected = await sqlite.testConnection();
if (!connected) {
return {
connected: false,
vectorExtension: false,
tablesExist: false,
error: 'SQLite connection failed'
};
}
const vectorExtension = await sqlite.checkVectorExtension();
// Check if main tables exist
const tables = await sqlite.checkTables();
const requiredTables = ['nodes', 'chunks', 'edges'];
const tablesExist = requiredTables.every(table => tables.includes(table));
return {
connected,
vectorExtension,
tablesExist
};
} catch (error) {
return {
connected: false,
vectorExtension: false,
tablesExist: false,
error: error instanceof Error ? error.message : 'Unknown error'
};
}
}
+350
View File
@@ -0,0 +1,350 @@
import { getSQLiteClient } from './sqlite-client';
import { Node, NodeFilters } from '@/types/database';
import { eventBroadcaster } from '../events';
export class NodeService {
async getNodes(filters: NodeFilters = {}): Promise<Node[]> {
return this.getNodesSQLite(filters);
}
// PostgreSQL path removed in SQLite-only consolidation
private async getNodesSQLite(filters: NodeFilters = {}): Promise<Node[]> {
const { dimensions, search, limit = 100, offset = 0, sortBy } = filters;
const sqlite = getSQLiteClient();
// Use nodes_v view for array-like dimensions behavior (exclude embedding BLOB for performance)
let query = `
SELECT n.id, n.title, n.description, n.content, n.link, n.type, 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,
(SELECT COUNT(*) FROM edges WHERE from_node_id = n.id OR to_node_id = n.id) as edge_count
FROM nodes n
WHERE 1=1
`;
const params: any[] = [];
// Filter by dimensions (SQLite JOIN with node_dimensions)
if (dimensions && dimensions.length > 0) {
query += ` AND EXISTS (
SELECT 1 FROM node_dimensions nd
WHERE nd.node_id = n.id
AND nd.dimension IN (${dimensions.map(() => '?').join(',')})
)`;
params.push(...dimensions);
}
// Text search in title and content (SQLite LIKE with COLLATE NOCASE)
if (search) {
query += ` AND (n.title LIKE ? COLLATE NOCASE OR n.content LIKE ? COLLATE NOCASE)`;
params.push(`%${search}%`, `%${search}%`);
}
// Sorting logic
if (search) {
// For search queries, prioritize by relevance: exact title → starts with → contains in title → content
query += ` ORDER BY
CASE WHEN LOWER(n.title) = LOWER(?) THEN 1 ELSE 5 END,
CASE WHEN LOWER(n.title) LIKE LOWER(?) THEN 2 ELSE 5 END,
CASE WHEN n.title LIKE ? COLLATE NOCASE THEN 3 ELSE 5 END,
CASE WHEN n.content LIKE ? COLLATE NOCASE THEN 4 ELSE 5 END,
n.updated_at DESC`;
params.push(
search, // Exact match (case-insensitive)
`${search}%`, // Starts with search term
`%${search}%`, // Contains in title
`%${search}%` // Contains in content
);
} else if (sortBy === 'edges') {
// Sort by edge count (most connected first)
query += ' ORDER BY edge_count DESC, n.updated_at DESC';
} else {
query += ' ORDER BY n.updated_at DESC';
}
if (limit) {
query += ` LIMIT ?`;
params.push(limit);
}
if (offset > 0) {
query += ` OFFSET ?`;
params.push(offset);
}
const result = sqlite.query<Node & { dimensions_json: string }>(query, params);
// Parse dimensions_json back to array for compatibility
return result.rows.map(row => ({
...row,
dimensions: JSON.parse(row.dimensions_json || '[]')
}));
}
async getNodeById(id: number): Promise<Node | null> {
return this.getNodeByIdSQLite(id);
}
// PostgreSQL path removed in SQLite-only consolidation
private async getNodeByIdSQLite(id: number): Promise<Node | null> {
const sqlite = getSQLiteClient();
const query = `
SELECT n.id, n.title, n.description, n.content, n.link, n.type, 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 = ?
`;
const result = sqlite.query<Node & { dimensions_json: string }>(query, [id]);
if (result.rows.length === 0) return null;
const row = result.rows[0];
return {
...row,
dimensions: JSON.parse(row.dimensions_json || '[]')
};
}
async createNode(nodeData: Partial<Node>): Promise<Node> {
return this.createNodeSQLite(nodeData);
}
// PostgreSQL path removed in SQLite-only consolidation
private async createNodeSQLite(nodeData: Partial<Node>): Promise<Node> {
const {
title,
description,
content,
link,
type,
dimensions = [],
chunk,
chunk_status,
metadata = {}
} = nodeData;
const now = new Date().toISOString();
const sqlite = getSQLiteClient();
const nodeId = sqlite.transaction(() => {
// Insert node using prepare/run for lastInsertRowid access
const nodeResult = sqlite.prepare(`
INSERT INTO nodes (title, description, content, link, type, metadata, chunk, chunk_status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
title,
description ?? null,
content ?? null,
link ?? null,
type ?? null,
JSON.stringify(metadata),
chunk ?? null,
chunk_status ?? null,
now,
now
);
const id = Number(nodeResult.lastInsertRowid);
// Insert dimensions separately with INSERT OR IGNORE for safety
if (dimensions.length > 0) {
const stmt = sqlite.prepare(
"INSERT OR IGNORE INTO node_dimensions (node_id, dimension) VALUES (?, ?)"
);
for (const dimension of dimensions) {
stmt.run(id, dimension);
}
}
return id; // Returns number directly
});
// Get the created node with dimensions (outside transaction)
const createdNode = await this.getNodeByIdSQLite(nodeId);
if (!createdNode) {
throw new Error('Failed to create node');
}
// Broadcast node creation event
console.log('🚀 Broadcasting NODE_CREATED event for:', createdNode.title);
eventBroadcaster.broadcast({
type: 'NODE_CREATED',
data: { node: createdNode }
});
return createdNode;
}
async updateNode(id: number, updates: Partial<Node>): Promise<Node> {
return this.updateNodeSQLite(id, updates);
}
// PostgreSQL path removed in SQLite-only consolidation
private async updateNodeSQLite(id: number, updates: Partial<Node>): Promise<Node> {
const { title, description, content, link, type, dimensions, chunk, metadata } = updates;
const now = new Date().toISOString();
const sqlite = getSQLiteClient();
const existingRow = sqlite
.query<{ id: number }>('SELECT id FROM nodes WHERE id = ?', [id])
.rows[0];
if (!existingRow) {
throw new Error(`Node with ID ${id} not found`);
}
sqlite.transaction(() => {
// Update node columns (only update provided fields)
const setFields: string[] = [];
const params: any[] = [];
if (title !== undefined) { setFields.push('title = ?'); params.push(title); }
if (description !== undefined) { setFields.push('description = ?'); params.push(description); }
if (content !== undefined) { setFields.push('content = ?'); params.push(content); }
if (link !== undefined) { setFields.push('link = ?'); params.push(link); }
if (type !== undefined) { setFields.push('type = ?'); params.push(type); }
if (chunk !== undefined) { setFields.push('chunk = ?'); params.push(chunk); }
if (Object.prototype.hasOwnProperty.call(updates, 'chunk_status')) {
setFields.push('chunk_status = ?');
params.push(updates.chunk_status ?? null);
}
if (metadata !== undefined) {
setFields.push('metadata = ?');
params.push(JSON.stringify(metadata));
}
// Always update timestamp
setFields.push('updated_at = ?');
params.push(now, id); // id for WHERE clause
if (setFields.length > 1) { // More than just updated_at
const stmt = sqlite.prepare(`UPDATE nodes SET ${setFields.join(', ')} WHERE id = ?`);
stmt.run(...params);
}
// Handle dimensions separately
if (Array.isArray(dimensions)) {
sqlite.prepare('DELETE FROM node_dimensions WHERE node_id = ?').run(id);
const dimStmt = sqlite.prepare('INSERT OR IGNORE INTO node_dimensions (node_id, dimension) VALUES (?, ?)');
for (const dim of dimensions) {
dimStmt.run(id, dim);
}
}
});
// Get updated node
const updatedNode = await this.getNodeByIdSQLite(id);
if (!updatedNode) {
throw new Error(`Node with ID ${id} not found`);
}
// Broadcast node update event
eventBroadcaster.broadcast({
type: 'NODE_UPDATED',
data: { nodeId: id, node: updatedNode }
});
return updatedNode;
}
async deleteNode(id: number): Promise<void> {
return this.deleteNodeSQLite(id);
}
// PostgreSQL path removed in SQLite-only consolidation
private async deleteNodeSQLite(id: number): Promise<void> {
const sqlite = getSQLiteClient();
const result = sqlite.query('DELETE FROM nodes WHERE id = ?', [id]);
if (result.changes === 0) {
throw new Error(`Node with ID ${id} not found`);
}
// Broadcast node deletion event
eventBroadcaster.broadcast({
type: 'NODE_DELETED',
data: { nodeId: id }
});
}
// Dimension-based filtering methods
async getNodesByDimension(dimension: string): Promise<Node[]> {
return this.getNodes({ dimensions: [dimension] });
}
async searchNodes(searchTerm: string, limit = 50): Promise<Node[]> {
return this.getNodes({ search: searchTerm, limit });
}
async getNodeCount(): Promise<number> {
const sqlite = getSQLiteClient();
const result = sqlite.query('SELECT COUNT(*) as count FROM nodes');
return Number(result.rows[0].count);
}
async bulkUpdateNodes(ids: number[], updates: Partial<Node>): Promise<Node[]> {
if (ids.length === 0) {
return [];
}
return this.bulkUpdateNodesSQLite(ids, updates);
}
// PostgreSQL path removed in SQLite-only consolidation
private async bulkUpdateNodesSQLite(ids: number[], updates: Partial<Node>): Promise<Node[]> {
// For SQLite, use IN (SELECT value FROM json_each(?)) for safety
const sqlite = getSQLiteClient();
const idsJson = JSON.stringify(ids);
// For now, just update one by one - could optimize later
const updatedNodes: Node[] = [];
for (const id of ids) {
const updated = await this.updateNodeSQLite(id, updates);
updatedNodes.push(updated);
}
return updatedNodes;
}
// Get all unique dimensions for UI filtering
async getAllDimensions(): Promise<string[]> {
const sqlite = getSQLiteClient();
const query = `
SELECT DISTINCT dimension
FROM node_dimensions
ORDER BY dimension
`;
const result = sqlite.query<{dimension: string}>(query);
return result.rows.map(row => row.dimension);
}
// Get dimension usage statistics
async getDimensionStats(): Promise<{dimension: string, count: number}[]> {
const sqlite = getSQLiteClient();
const query = `
SELECT dimension, COUNT(*) as count
FROM node_dimensions
GROUP BY dimension
ORDER BY count DESC
`;
const result = sqlite.query<{dimension: string, count: number}>(query);
return result.rows;
}
}
// Export singleton instance
export const nodeService = new NodeService();
// Legacy export for backwards compatibility during migration
export const itemService = nodeService;
export const ItemService = NodeService;
+686
View File
@@ -0,0 +1,686 @@
import Database from 'better-sqlite3';
import fs from 'fs';
import path from 'path';
import { DatabaseError } from '@/types/database';
export interface SQLiteConfig {
dbPath: string;
vecExtensionPath: string;
}
export interface SQLiteQueryResult<T = any> {
rows: T[];
changes?: number;
lastInsertRowid?: number;
}
class SQLiteClient {
private static instance: SQLiteClient;
private db: Database.Database;
private config: SQLiteConfig;
private readonly readOnly: boolean;
private constructor() {
this.config = this.getSQLiteConfig();
this.readOnly = process.env.SQLITE_READONLY === 'true';
// Initialize database connection
const dbDirectory = path.dirname(this.config.dbPath);
if (!this.readOnly && !fs.existsSync(dbDirectory)) {
fs.mkdirSync(dbDirectory, { recursive: true });
}
this.db = this.readOnly
? new Database(this.config.dbPath, { readonly: true, fileMustExist: true })
: new Database(this.config.dbPath);
// Load sqlite-vec extension
try {
this.db.loadExtension(this.config.vecExtensionPath);
console.log('SQLite vector extension loaded successfully');
} catch (error) {
// Do not fail hard — allow the app to run without vector features
console.error('Warning: Failed to load vector extension:', error);
}
// Configure SQLite settings
if (this.readOnly) {
try {
this.db.pragma('query_only = ON');
} catch (error) {
console.warn('Failed to enable query_only pragma in read-only mode:', error);
}
} else {
this.db.pragma('foreign_keys = ON');
this.db.pragma('journal_mode = WAL');
this.db.pragma('synchronous = NORMAL');
this.db.pragma('cache_size = 10000');
this.db.pragma('temp_store = memory');
this.db.pragma('busy_timeout = 5000');
// Ensure vector virtual tables are present and healthy
this.ensureVectorTables();
this.healVectorTablesIfCorrupt();
// Ensure logging schema (rename memory->logs if needed, create triggers/views)
this.ensureLoggingAndMemorySchema();
}
console.log('SQLite client initialized successfully');
}
private getSQLiteConfig(): SQLiteConfig {
const dbPath = process.env.SQLITE_DB_PATH || path.join(
process.env.HOME || '~',
'Library/Application Support/RA-H/db/rah.sqlite'
);
const vecExtensionPath = process.env.SQLITE_VEC_EXTENSION_PATH ||
'./vendor/sqlite-extensions/vec0.dylib';
return {
dbPath,
vecExtensionPath
};
}
public static getInstance(): SQLiteClient {
if (!SQLiteClient.instance) {
SQLiteClient.instance = new SQLiteClient();
}
return SQLiteClient.instance;
}
public query<T extends Record<string, any> = any>(
sql: string,
params?: any[]
): SQLiteQueryResult<T> {
try {
const sqlLower = sql.trim().toLowerCase();
// Handle different query types
if (sqlLower.startsWith('select') ||
sqlLower.startsWith('with') ||
sqlLower.includes('returning')) {
// SELECT queries and queries with RETURNING clause
const stmt = this.db.prepare(sql);
const rows = params ? stmt.all(...params) : stmt.all();
return { rows: rows as T[] };
} else {
// INSERT/UPDATE/DELETE queries without RETURNING
const stmt = this.db.prepare(sql);
const result = params ? stmt.run(...params) : stmt.run();
return {
rows: [],
changes: result.changes,
lastInsertRowid: Number(result.lastInsertRowid)
};
}
} catch (error) {
console.error('SQLite query error:', error);
throw this.handleError(error);
}
}
public prepare(sql: string) {
return this.db.prepare(sql);
}
public transaction<T>(callback: () => T): T {
if (this.readOnly) {
throw {
message: 'SQLite client is read-only',
code: 'SQLITE_READONLY',
details: 'Transactions are not allowed in read-only mode'
} as DatabaseError;
}
// Proactively validate/repair vec vtables before any write transaction
this.healVectorTablesIfCorrupt();
const txn = this.db.transaction(callback);
try {
return txn();
} catch (error) {
throw this.handleError(error);
}
}
public async testConnection(): Promise<boolean> {
try {
const result = this.query('SELECT datetime() as current_time');
return result.rows.length > 0;
} catch (error) {
console.error('SQLite connection test failed:', error);
return false;
}
}
public async checkVectorExtension(): Promise<boolean> {
try {
const result = this.query('SELECT vec_version() as version');
return result.rows.length > 0;
} catch (error) {
console.error('Vector extension check failed:', error);
return false;
}
}
public async checkTables(): Promise<string[]> {
try {
const result = this.query(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
);
return result.rows.map(row => row.name);
} catch (error) {
console.error('Table check failed:', error);
return [];
}
}
public ensureVectorExtensions(): void {
try {
// Test for vec_nodes and vec_chunks; create them if missing
const hasVecNodes = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get('vec_nodes');
if (!hasVecNodes) {
this.db.exec(`
CREATE VIRTUAL TABLE vec_nodes USING vec0(
node_id INTEGER PRIMARY KEY,
embedding FLOAT[1536]
);
`);
console.log('Created vec_nodes virtual table');
}
const hasVecChunks = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get('vec_chunks');
if (!hasVecChunks) {
this.db.exec(`
CREATE VIRTUAL TABLE vec_chunks USING vec0(
chunk_id INTEGER PRIMARY KEY,
embedding FLOAT[1536]
);
`);
console.log('Created vec_chunks virtual table');
}
} catch (error) {
console.warn('Vector extension not available:', error);
}
}
private ensureVectorTables(): void {
if (this.readOnly) {
return;
}
// Wrapper to keep existing public API stable
this.ensureVectorExtensions();
}
private ensureLoggingAndMemorySchema(): void {
if (this.readOnly) {
return;
}
try {
// 1) If logs table missing but legacy memory table exists, migrate
const hasLogs = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='logs'").get();
const hasLegacyMemory = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='memory'").get();
if (!hasLogs && hasLegacyMemory) {
// Drop old view to release dependency
this.db.exec(`DROP VIEW IF EXISTS memory_v;`);
this.db.exec(`ALTER TABLE memory RENAME TO logs;`);
}
// 2) Ensure logs table exists (fresh install)
const hasLogsNow = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='logs'").get();
if (!hasLogsNow) {
this.db.exec(`
CREATE TABLE logs (
id INTEGER PRIMARY KEY,
ts TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP),
table_name TEXT NOT NULL,
action TEXT NOT NULL,
row_id INTEGER NOT NULL,
summary TEXT,
snapshot_json TEXT
);
`);
}
// Ensure nodes table has expected columns for memory nodes
try {
const nodeCols = this.db.prepare('PRAGMA table_info(nodes)').all() as Array<{ name: string }>;
const ensureNodeCol = (name: string, ddl: string) => {
if (!nodeCols.some(col => col.name === name)) {
try {
this.db.exec(ddl);
} catch (colErr) {
console.warn(`Failed to add nodes.${name}`, colErr);
}
}
};
ensureNodeCol('description', "ALTER TABLE nodes ADD COLUMN description TEXT;");
ensureNodeCol('type', "ALTER TABLE nodes ADD COLUMN type TEXT;");
} catch (nodeErr) {
console.warn('Failed to ensure nodes columns:', nodeErr);
}
// Ensure chats table tracks creation timestamp for ordering
try {
const chatCols = this.db.prepare('PRAGMA table_info(chats)').all() as Array<{ name: string }>;
if (chatCols.some(col => col.name === 'created_at')) {
// no-op, column exists
} else if (chatCols.length > 0) {
this.db.exec("ALTER TABLE chats ADD COLUMN created_at TEXT DEFAULT (CURRENT_TIMESTAMP);");
}
} catch (chatErr) {
console.warn('Failed to ensure chats.created_at column:', chatErr);
}
// 3) Helpful indexes on logs (clean up old names first)
this.db.exec(`
DROP INDEX IF EXISTS idx_memory_ts;
DROP INDEX IF EXISTS idx_memory_table_ts;
DROP INDEX IF EXISTS idx_memory_table_row;
CREATE INDEX IF NOT EXISTS idx_logs_ts ON logs(ts);
CREATE INDEX IF NOT EXISTS idx_logs_table_ts ON logs(table_name, ts);
CREATE INDEX IF NOT EXISTS idx_logs_table_row ON logs(table_name, row_id);
`);
// 4) Recreate triggers to write to logs (use CREATE IF NOT EXISTS)
const hasChats = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chats'").get();
this.db.exec(`
DROP TRIGGER IF EXISTS trg_nodes_ai;
DROP TRIGGER IF EXISTS trg_nodes_au;
CREATE TRIGGER IF NOT EXISTS trg_nodes_ai AFTER INSERT ON nodes BEGIN
INSERT INTO logs(table_name, action, row_id, summary, snapshot_json)
VALUES('nodes', 'insert', NEW.id,
printf('node created: %s', COALESCE(NEW.title,'')),
json_object('id', NEW.id, 'title', NEW.title, 'link', NEW.link));
END;
CREATE TRIGGER IF NOT EXISTS trg_nodes_au AFTER UPDATE ON nodes BEGIN
INSERT INTO logs(table_name, action, row_id, summary, snapshot_json)
VALUES('nodes', 'update', NEW.id,
printf('node updated: %s', COALESCE(NEW.title,'')),
json_object('id', NEW.id, 'title', NEW.title, 'link', NEW.link));
END;
DROP TRIGGER IF EXISTS trg_edges_ai;
DROP TRIGGER IF EXISTS trg_edges_au;
CREATE TRIGGER IF NOT EXISTS trg_edges_ai AFTER INSERT ON edges BEGIN
INSERT INTO logs(table_name, action, row_id, summary, snapshot_json)
VALUES('edges', 'insert', NEW.id,
printf('edge %d→%d (%s)', NEW.from_node_id, NEW.to_node_id, COALESCE(NEW.source,'')),
json_object(
'id', NEW.id,
'from', NEW.from_node_id,
'to', NEW.to_node_id,
'source', NEW.source,
'from_title', substr((SELECT title FROM nodes WHERE id = NEW.from_node_id), 1, 120),
'to_title', substr((SELECT title FROM nodes WHERE id = NEW.to_node_id), 1, 120)
));
END;
CREATE TRIGGER IF NOT EXISTS trg_edges_au AFTER UPDATE ON edges BEGIN
INSERT INTO logs(table_name, action, row_id, summary, snapshot_json)
VALUES('edges', 'update', NEW.id,
printf('edge updated %d→%d', NEW.from_node_id, NEW.to_node_id),
json_object(
'id', NEW.id,
'from', NEW.from_node_id,
'to', NEW.to_node_id,
'source', NEW.source,
'from_title', substr((SELECT title FROM nodes WHERE id = NEW.from_node_id), 1, 120),
'to_title', substr((SELECT title FROM nodes WHERE id = NEW.to_node_id), 1, 120)
));
END;
`);
if (hasChats) {
this.db.exec(`
DROP TRIGGER IF EXISTS trg_chats_ai;
CREATE TRIGGER IF NOT EXISTS trg_chats_ai AFTER INSERT ON chats BEGIN
INSERT INTO logs(table_name, action, row_id, summary, snapshot_json)
VALUES('chats', 'insert', NEW.id,
printf('chat: %s (%s)', COALESCE(NEW.helper_name,''), COALESCE(NEW.thread_id,'')),
json_object(
'id', NEW.id,
'helper', NEW.helper_name,
'thread', NEW.thread_id,
'user_message', COALESCE(NEW.user_message,''),
'assistant_message', COALESCE(NEW.assistant_message,''),
'user_preview', substr(COALESCE(NEW.user_message,''), 1, 120),
'assistant_preview', substr(COALESCE(NEW.assistant_message,''), 1, 120),
'system_message', COALESCE(json_extract(NEW.metadata, '$.system_message'), ''),
'input_tokens', COALESCE(json_extract(NEW.metadata, '$.input_tokens'), 0),
'output_tokens', COALESCE(json_extract(NEW.metadata, '$.output_tokens'), 0),
'cost_usd', COALESCE(json_extract(NEW.metadata, '$.estimated_cost_usd'), 0.0),
'cache_hit', COALESCE(json_extract(NEW.metadata, '$.cache_hit'), 0),
'model', COALESCE(json_extract(NEW.metadata, '$.model_used'), ''),
'tools_count', COALESCE(json_extract(NEW.metadata, '$.tool_calls_count'), 0),
'trace_id', COALESCE(json_extract(NEW.metadata, '$.trace_id'), ''),
'voice_tts_chars', COALESCE(json_extract(NEW.metadata, '$.voice_tts_chars'), 0),
'voice_tts_cost_usd', COALESCE(json_extract(NEW.metadata, '$.voice_tts_cost_usd'), 0),
'voice_tts_chars_total', COALESCE(json_extract(NEW.metadata, '$.voice_tts_chars_total'), 0),
'voice_tts_cost_usd_total', COALESCE(json_extract(NEW.metadata, '$.voice_tts_cost_usd_total'), 0),
'voice_request_id', COALESCE(json_extract(NEW.metadata, '$.voice_request_id'), ''),
'voice_tts_request_count', COALESCE(json_extract(NEW.metadata, '$.voice_tts_request_count'), 0)
));
END;
`);
}
this.db.exec(`
CREATE TABLE IF NOT EXISTS voice_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_id INTEGER,
session_id TEXT,
helper_name TEXT,
request_id TEXT,
message_id TEXT,
voice TEXT,
model TEXT,
chars INTEGER,
cost_usd REAL,
duration_ms INTEGER,
text_preview TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_voice_usage_session ON voice_usage(session_id, created_at);
CREATE INDEX IF NOT EXISTS idx_voice_usage_chat ON voice_usage(chat_id);
`);
// 5) Views: logs_v (drop any legacy memory_v alias)
this.db.exec(`DROP VIEW IF EXISTS logs_v; DROP VIEW IF EXISTS memory_v;`);
try {
this.db.exec(`
CREATE VIEW logs_v AS
SELECT
m.id,
m.ts,
m.table_name,
m.action,
m.row_id,
m.summary,
m.enriched_summary,
m.snapshot_json,
CASE WHEN m.table_name='nodes' THEN n.title END AS node_title,
CASE WHEN m.table_name='edges' THEN nf.title END AS edge_from_title,
CASE WHEN m.table_name='edges' THEN nt.title END AS edge_to_title,
CASE WHEN m.table_name='chats' THEN c.helper_name END AS chat_helper,
CASE WHEN m.table_name='chats' THEN substr(c.user_message,1,120) END AS chat_user_preview,
CASE WHEN m.table_name='chats' THEN substr(c.assistant_message,1,120) END AS chat_assistant_preview,
CASE WHEN m.table_name='chats' THEN c.user_message END AS chat_user_full,
CASE WHEN m.table_name='chats' THEN c.assistant_message END AS chat_assistant_full
FROM logs m
LEFT JOIN nodes n ON (m.table_name='nodes' AND m.row_id = n.id)
LEFT JOIN edges e ON (m.table_name='edges' AND m.row_id = e.id)
LEFT JOIN nodes nf ON e.from_node_id = nf.id
LEFT JOIN nodes nt ON e.to_node_id = nt.id
LEFT JOIN chats c ON (m.table_name='chats' AND m.row_id = c.id);
`);
} catch (error) {
if (
!(error instanceof Error) ||
!/already exists/i.test(error.message || '')
) {
throw error;
}
}
// Do not recreate memory_v; alias has been removed.
// 6) Chat memory state tracking for chat-triggered memories
this.db.exec(`
CREATE TABLE IF NOT EXISTS chat_memory_state (
thread_id TEXT PRIMARY KEY,
helper_name TEXT,
last_processed_chat_id INTEGER DEFAULT 0,
last_processed_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_chat_memory_thread ON chat_memory_state(thread_id);
`);
// Agent delegation table for orchestrator/worker coordination
try {
this.db.exec(`
CREATE TABLE IF NOT EXISTS agent_delegations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT UNIQUE NOT NULL,
task TEXT NOT NULL,
context TEXT,
expected_outcome TEXT,
status TEXT NOT NULL DEFAULT 'queued',
summary TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
`);
} catch (e) {
console.warn('Failed to ensure agent_delegations table:', e);
}
// 8) Logs retention trigger (~10k most recent rows)
try {
this.db.exec(`
DROP TRIGGER IF EXISTS trg_logs_prune;
CREATE TRIGGER IF NOT EXISTS trg_logs_prune AFTER INSERT ON logs BEGIN
DELETE FROM logs WHERE id < NEW.id - 10000;
END;
`);
} catch {}
// 7) Ensure agents table schema (backward compatibility)
try {
const agentCols = this.db.prepare('PRAGMA table_info(agents)').all() as any[];
if (agentCols.length) {
const hasKey = agentCols.some(col => col.name === 'key');
const hasComponentKey = agentCols.some(col => col.name === 'component_key');
if (!hasKey && hasComponentKey) {
try { this.db.exec('ALTER TABLE agents RENAME COLUMN component_key TO key;'); } catch {}
}
if (!agentCols.some(col => col.name === 'role')) {
try { this.db.exec("ALTER TABLE agents ADD COLUMN role TEXT NOT NULL DEFAULT 'executor';"); } catch {}
}
if (!agentCols.some(col => col.name === 'memory')) {
try { this.db.exec('ALTER TABLE agents ADD COLUMN memory TEXT;'); } catch {}
}
if (!agentCols.some(col => col.name === 'prompts')) {
try { this.db.exec("ALTER TABLE agents ADD COLUMN prompts TEXT DEFAULT '[]';"); } catch {}
}
}
} catch (e) {
console.warn('Agent schema ensure failed:', e);
}
// 8) Ensure chats schema (remove legacy focused_memory_id, ensure agent columns)
if (hasChats) {
try {
let chatCols = this.db.prepare('PRAGMA table_info(chats)').all() as any[];
const hasFocusedMemoryId = chatCols.some((c: any) => c.name === 'focused_memory_id');
if (hasFocusedMemoryId) {
console.log('Removing legacy chats.focused_memory_id column');
let flippedForeignKeys = false;
try {
this.db.exec('PRAGMA foreign_keys=OFF;');
flippedForeignKeys = true;
this.db.exec(`
BEGIN TRANSACTION;
ALTER TABLE chats RENAME TO chats_legacy_cleanup;
CREATE TABLE chats (
id INTEGER PRIMARY KEY,
chat_type TEXT,
helper_name TEXT,
agent_type TEXT DEFAULT 'orchestrator',
delegation_id INTEGER,
user_message TEXT,
assistant_message TEXT,
thread_id TEXT,
focused_node_id INTEGER,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP),
metadata TEXT,
FOREIGN KEY (focused_node_id) REFERENCES nodes(id) ON DELETE SET NULL
);
INSERT INTO chats (
id, chat_type, helper_name, agent_type, delegation_id,
user_message, assistant_message, thread_id, focused_node_id,
created_at, metadata
)
SELECT id, chat_type, helper_name, agent_type, delegation_id,
user_message, assistant_message, thread_id, focused_node_id,
created_at, metadata
FROM chats_legacy_cleanup;
DROP TABLE chats_legacy_cleanup;
CREATE INDEX IF NOT EXISTS idx_chats_thread ON chats(thread_id);
COMMIT;
`);
} catch (migrationErr) {
console.warn('Failed to migrate chats table (focused_memory_id removal):', migrationErr);
try { this.db.exec('ROLLBACK;'); } catch {}
} finally {
if (flippedForeignKeys) {
try { this.db.exec('PRAGMA foreign_keys=ON;'); } catch {}
}
}
chatCols = this.db.prepare('PRAGMA table_info(chats)').all() as any[];
}
this.db.exec("CREATE INDEX IF NOT EXISTS idx_chats_thread ON chats(thread_id);");
const ensureCol = (name: string, ddl: string) => {
if (!chatCols.some((c: any) => c.name === name)) {
try { this.db.exec(ddl); } catch (colErr) { console.warn(`Failed to add chats.${name}`, colErr); }
}
};
ensureCol('agent_type', "ALTER TABLE chats ADD COLUMN agent_type TEXT DEFAULT 'orchestrator';");
ensureCol('delegation_id', 'ALTER TABLE chats ADD COLUMN delegation_id INTEGER;');
} catch (e) {
console.warn('Failed to update chats schema:', e);
}
}
try {
const chatColsPost = hasChats
? this.db.prepare('PRAGMA table_info(chats)').all() as any[]
: [];
const stillHasFocusedMemoryId = chatColsPost.some((c: any) => c.name === 'focused_memory_id');
if (stillHasFocusedMemoryId) {
console.warn('Skipping legacy memory table drop because chats.focused_memory_id is still present.');
} else {
this.db.exec(`
DROP TRIGGER IF EXISTS trg_episodic_prune;
DROP TABLE IF EXISTS episodic_memory;
DROP TABLE IF EXISTS episodic_pipeline_state;
DROP TABLE IF EXISTS semantic_memory;
DROP TABLE IF EXISTS semantic_pipeline_state;
DROP TABLE IF EXISTS memory_pipeline_state;
DROP TABLE IF EXISTS memory;
`);
}
} catch (dropLegacyErr) {
console.warn('Failed to drop legacy memory pipeline tables:', dropLegacyErr);
}
// 9) Ensure dimensions table exists (v0.1.16+ schema migration)
const hasDimensions = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='dimensions'").get();
if (!hasDimensions) {
console.log('Creating dimensions table for v0.1.16+ features...');
this.db.exec(`
CREATE TABLE dimensions (
name TEXT PRIMARY KEY,
description TEXT,
is_priority INTEGER DEFAULT 0,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
`);
// Seed default locked dimensions
const defaultDimensions = ['research', 'ideas', 'projects', 'memory', 'preferences'];
const insertDimension = this.db.prepare(`
INSERT INTO dimensions (name, is_priority, updated_at)
VALUES (?, 1, datetime('now'))
ON CONFLICT(name) DO UPDATE SET is_priority = 1, updated_at = datetime('now')
`);
for (const dimension of defaultDimensions) {
try {
insertDimension.run(dimension);
} catch (e) {
console.warn(`Failed to seed dimension '${dimension}':`, e);
}
}
console.log('Dimensions table created and seeded with default locked dimensions');
} else {
// Check if existing dimensions table has description column
const dimensionCols = this.db.prepare('PRAGMA table_info(dimensions)').all() as Array<{ name: string }>;
const hasDescription = dimensionCols.some(col => col.name === 'description');
if (!hasDescription) {
console.log('Adding description column to existing dimensions table...');
try {
this.db.exec('ALTER TABLE dimensions ADD COLUMN description TEXT;');
console.log('Description column added to dimensions table');
} catch (e) {
console.warn('Failed to add description column to dimensions table:', e);
}
}
}
console.log('Logging + memory schema ensured');
} catch (error) {
console.error('Failed to ensure logging/memory schema:', error);
}
}
private healVectorTablesIfCorrupt(): void {
if (this.readOnly) {
return;
}
// Attempt lightweight reads to detect CORRUPT_VTAB; if detected, drop/recreate vtables
const tryRead = (table: string) => {
try {
this.db.prepare(`SELECT COUNT(*) as c FROM ${table}`).get();
} catch (e: any) {
const msg = String(e?.message || '');
const code = (e && e.code) ? String(e.code) : '';
if (code === 'SQLITE_CORRUPT_VTAB' || msg.includes('database disk image is malformed') || msg.includes('CORRUPT_VTAB')) {
console.warn(`Detected corrupted virtual table ${table} (${code || 'error'}). Recreating...`);
try {
this.db.exec(`DROP TABLE IF EXISTS ${table};`);
} catch {}
const ddl = table === 'vec_nodes'
? `CREATE VIRTUAL TABLE vec_nodes USING vec0(node_id INTEGER PRIMARY KEY, embedding FLOAT[1536]);`
: `CREATE VIRTUAL TABLE vec_chunks USING vec0(chunk_id INTEGER PRIMARY KEY, embedding FLOAT[1536]);`;
try {
this.db.exec(ddl);
console.log(`Recreated ${table} virtual table`);
} catch (re) {
console.error(`Failed to recreate ${table}:`, re);
}
} else {
// Other errors should bubble up normally
// eslint-disable-next-line no-unsafe-finally
throw e;
}
}
};
tryRead('vec_nodes');
tryRead('vec_chunks');
}
private handleError(error: any): DatabaseError {
return {
message: error.message || 'SQLite operation failed',
code: error.code || 'SQLITE_ERROR',
details: error
};
}
public close(): void {
this.db.close();
}
}
// Export singleton instance (similar to PostgreSQL client interface)
export const sqliteDb = SQLiteClient.getInstance();
// Export function to get client instance
export const getSQLiteClient = () => sqliteDb;
// Export class for testing
export { SQLiteClient };
+113
View File
@@ -0,0 +1,113 @@
import { embedNodeContent } from '@/services/embedding/ingestion';
import { nodeService } from '@/services/database';
interface AutoEmbedTask {
nodeId: number;
force?: boolean;
reason?: string;
}
const DEFAULT_COOLDOWN_MS = 2 * 60 * 1000; // 2 minutes between automatic runs per node
export class AutoEmbedQueue {
private readonly queue: number[] = [];
private readonly pendingTasks = new Map<number, AutoEmbedTask>();
private readonly running = new Set<number>();
private readonly lastRunAt = new Map<number, number>();
private readonly maxConcurrent = 1;
private readonly cooldownMs = DEFAULT_COOLDOWN_MS;
enqueue(nodeId: number, task: Omit<AutoEmbedTask, 'nodeId'> = {}): boolean {
const existing = this.pendingTasks.get(nodeId);
if (!existing) {
this.pendingTasks.set(nodeId, { nodeId, ...task });
this.queue.push(nodeId);
} else {
existing.force = existing.force || task.force;
existing.reason = existing.reason || task.reason;
}
this.processQueue();
return true;
}
private processQueue() {
if (this.running.size >= this.maxConcurrent) {
return;
}
const nextId = this.queue.shift();
if (typeof nextId !== 'number') {
return;
}
const task = this.pendingTasks.get(nextId);
if (!task) {
// Task was removed; try next
this.processQueue();
return;
}
this.pendingTasks.delete(nextId);
const now = Date.now();
const lastRun = this.lastRunAt.get(task.nodeId);
if (!task.force && lastRun && now - lastRun < this.cooldownMs) {
const delay = this.cooldownMs - (now - lastRun);
setTimeout(() => this.enqueue(task.nodeId, task), delay);
this.processQueue();
return;
}
this.running.add(task.nodeId);
this.executeTask(task)
.catch(error => {
console.error('[AutoEmbedQueue] Task failed', task.nodeId, error);
})
.finally(() => {
this.running.delete(task.nodeId);
this.lastRunAt.set(task.nodeId, Date.now());
if (this.queue.length > 0) {
setTimeout(() => this.processQueue(), 10);
}
});
}
private async executeTask(task: AutoEmbedTask) {
const node = await nodeService.getNodeById(task.nodeId);
if (!node) {
console.warn('[AutoEmbedQueue] Node missing, skipping', task.nodeId);
return;
}
const chunkText = node.chunk?.trim();
if (!chunkText) {
console.warn('[AutoEmbedQueue] Node has no chunk content, skipping', task.nodeId);
return;
}
if (!task.force && node.chunk_status === 'chunked') {
return;
}
if (node.chunk_status === 'chunking' && !task.force) {
console.log('[AutoEmbedQueue] Node already chunking, skipping duplicate run', task.nodeId);
return;
}
console.log(`🔄 [AutoEmbedQueue] Embedding node ${task.nodeId}${task.reason ? ` (${task.reason})` : ''}`);
const result = await embedNodeContent(task.nodeId);
if (!result.success) {
console.error('[AutoEmbedQueue] Embedding failed', task.nodeId, result.error);
}
}
}
declare global {
// eslint-disable-next-line no-var
var autoEmbedQueue: AutoEmbedQueue | undefined;
}
export const autoEmbedQueue = globalThis.autoEmbedQueue ?? new AutoEmbedQueue();
if (!globalThis.autoEmbedQueue) {
globalThis.autoEmbedQueue = autoEmbedQueue;
}
+8
View File
@@ -0,0 +1,8 @@
export const AUTO_EMBED_MIN_CHARS = 200;
export function hasSufficientContent(text?: string | null): boolean {
if (typeof text !== 'string') {
return false;
}
return text.trim().length >= AUTO_EMBED_MIN_CHARS;
}
+158
View File
@@ -0,0 +1,158 @@
import { nodeService } from '@/services/database';
import { NodeEmbedder } from '@/services/typescript/embed-nodes';
import { UniversalEmbedder } from '@/services/typescript/embed-universal';
import type { Node } from '@/types/database';
export interface EmbeddingStageStatus {
status: 'pending' | 'completed' | 'failed' | 'skipped';
message: string;
chunks_created?: number;
}
export interface EmbeddingPipelineResult {
success: boolean;
error?: string;
node_embedding: EmbeddingStageStatus;
chunk_embeddings: EmbeddingStageStatus;
overall_status: 'pending' | 'fully_embedded' | 'partially_embedded' | 'no_content' | 'failed';
}
interface EmbeddingResult {
success: boolean;
error?: string;
output?: string;
}
async function runNodeEmbedding(nodeId: number): Promise<EmbeddingResult> {
const embedder = new NodeEmbedder();
try {
const result = await embedder.embedNodes({ nodeId });
if (result.processed > 0) {
return { success: true, output: `Embedded ${result.processed} nodes` };
}
if (result.failed > 0) {
return { success: false, error: 'Failed to embed node' };
}
return { success: true, output: 'Node already has embedding' };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Embedding failed'
};
} finally {
embedder.close();
}
}
async function updateChunkStatus(nodeId: number, status: Node['chunk_status']) {
try {
await nodeService.updateNode(nodeId, { chunk_status: status });
} catch (error) {
console.warn('Failed to update chunk_status for node', nodeId, status, error);
}
}
async function runChunkEmbedding(nodeId: number): Promise<EmbeddingResult> {
const embedder = new UniversalEmbedder();
try {
await updateChunkStatus(nodeId, 'chunking');
const result = await embedder.processNode({ nodeId });
return {
success: true,
output: `Stored ${result.chunks} chunks`
};
} catch (error) {
await updateChunkStatus(nodeId, 'error');
return {
success: false,
error: error instanceof Error ? error.message : 'Chunking failed'
};
} finally {
embedder.close();
}
}
export async function embedNodeContent(nodeId: number): Promise<EmbeddingPipelineResult> {
const node = await nodeService.getNodeById(nodeId);
if (!node) {
return {
success: false,
error: 'Node not found',
node_embedding: { status: 'failed', message: 'Node not found' },
chunk_embeddings: { status: 'failed', message: 'Node not found' },
overall_status: 'failed'
};
}
const results = {
node_embedding: { status: 'pending', message: '' } as EmbeddingStageStatus,
chunk_embeddings: { status: 'pending', message: '', chunks_created: 0 } as EmbeddingStageStatus,
overall_status: 'pending' as EmbeddingPipelineResult['overall_status']
};
const nodeResult = await runNodeEmbedding(nodeId);
if (nodeResult.success) {
results.node_embedding = {
status: 'completed',
message: nodeResult.output || 'Node metadata embedded successfully'
};
} else {
results.node_embedding = {
status: 'failed',
message: nodeResult.error || 'Failed to embed node metadata'
};
}
if (!node.chunk || !node.chunk.trim()) {
results.chunk_embeddings = {
status: 'skipped',
message: 'No chunk content to embed',
chunks_created: 0
};
} else {
const chunkResult = await runChunkEmbedding(nodeId);
if (chunkResult.success) {
const chunkMatch = chunkResult.output?.match(/Stored (\d+) chunks/);
const chunksCreated = chunkMatch ? parseInt(chunkMatch[1], 10) : 0;
results.chunk_embeddings = {
status: 'completed',
message: chunkResult.output || 'Chunk content embedded successfully',
chunks_created: chunksCreated
};
} else {
results.chunk_embeddings = {
status: 'failed',
message: chunkResult.error || 'Failed to embed chunk content'
};
}
}
const nodeSuccess = results.node_embedding.status === 'completed';
const chunkSuccess = results.chunk_embeddings.status === 'completed';
const chunkSkipped = results.chunk_embeddings.status === 'skipped';
if (nodeSuccess && (chunkSuccess || chunkSkipped)) {
results.overall_status = chunkSkipped ? 'no_content' : 'fully_embedded';
if (chunkSuccess) {
await updateChunkStatus(nodeId, 'chunked');
}
} else if (nodeSuccess || chunkSuccess) {
results.overall_status = 'partially_embedded';
} else {
results.overall_status = 'failed';
}
const errorParts: string[] = [];
if (results.node_embedding.status === 'failed') {
errorParts.push(`node: ${results.node_embedding.message}`);
}
if (results.chunk_embeddings.status === 'failed') {
errorParts.push(`chunks: ${results.chunk_embeddings.message}`);
}
return {
success: results.overall_status !== 'failed',
error: errorParts.length ? errorParts.join('; ') : undefined,
...results
};
}
+44
View File
@@ -0,0 +1,44 @@
import OpenAI from 'openai';
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 class EmbeddingService {
/**
* Generate embedding for a search query using OpenAI's text-embedding-3-small model
* This matches the same model used in embed_universal.py for consistency
*/
static async generateQueryEmbedding(query: string): Promise<number[]> {
try {
const openai = getOpenAiClient();
const response = await openai.embeddings.create({
model: "text-embedding-3-small",
input: query.trim(),
encoding_format: "float"
});
if (!response.data?.[0]?.embedding) {
throw new Error('No embedding returned from OpenAI API');
}
return response.data[0].embedding;
} catch (error) {
console.error('Failed to generate query embedding:', error);
throw new Error(`Embedding generation failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Validate embedding dimensions match expected size (1536 for text-embedding-3-small)
*/
static validateEmbedding(embedding: number[]): boolean {
return Array.isArray(embedding) && embedding.length === 1536;
}
}
+117
View File
@@ -0,0 +1,117 @@
/**
* Event Broadcasting Service for Real-time UI Updates
* Manages SSE connections and broadcasts database change events
*/
export interface DatabaseEvent {
type:
| 'NODE_CREATED'
| 'NODE_UPDATED'
| 'NODE_DELETED'
| 'EDGE_CREATED'
| 'EDGE_DELETED'
| 'DIMENSION_UPDATED'
| 'HELPER_UPDATED'
| 'AGENT_UPDATED'
| 'AGENT_DELEGATION_CREATED'
| 'AGENT_DELEGATION_UPDATED'
| 'WORKFLOW_PROGRESS'
| 'CONNECTION_ESTABLISHED';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: any;
timestamp: number;
}
class EventBroadcaster {
private connections = new Set<ReadableStreamDefaultController>();
/**
* Add a new SSE connection
*/
addConnection(controller: ReadableStreamDefaultController) {
this.connections.add(controller);
}
/**
* Remove an SSE connection
*/
removeConnection(controller: ReadableStreamDefaultController) {
this.connections.delete(controller);
}
/**
* Broadcast an event to all connected clients
*/
broadcast(event: Omit<DatabaseEvent, 'timestamp'>) {
console.log(`📡 Broadcasting ${event.type} to ${this.connections.size} connections`);
const eventWithTimestamp: DatabaseEvent = {
...event,
timestamp: Date.now()
};
const message = `data: ${JSON.stringify(eventWithTimestamp)}\n\n`;
const encoder = new TextEncoder();
const data = encoder.encode(message);
// Send to all connected clients
let successCount = 0;
for (const controller of this.connections) {
try {
controller.enqueue(data);
successCount++;
} catch (error) {
// Connection is closed, remove it
console.log('🔌 Removing dead SSE connection');
this.connections.delete(controller);
}
}
console.log(`✅ Broadcasted to ${successCount} active connections`);
}
/**
* Send keep-alive ping to maintain connections
*/
sendKeepAlive() {
const ping = `: keep-alive\n\n`;
const encoder = new TextEncoder();
const data = encoder.encode(ping);
for (const controller of this.connections) {
try {
controller.enqueue(data);
} catch (error) {
this.connections.delete(controller);
}
}
}
/**
* Get connection count for debugging
*/
getConnectionCount(): number {
return this.connections.size;
}
}
// Global singleton instance with proper Next.js dev mode handling
declare global {
// eslint-disable-next-line no-var
var eventBroadcaster: EventBroadcaster | undefined;
// eslint-disable-next-line no-var
var keepAliveInterval: NodeJS.Timeout | undefined;
}
export const eventBroadcaster = globalThis.eventBroadcaster ?? new EventBroadcaster();
if (typeof window === 'undefined') {
globalThis.eventBroadcaster = eventBroadcaster;
// Keep-alive interval (every 30 seconds) - only create once
if (!globalThis.keepAliveInterval) {
globalThis.keepAliveInterval = setInterval(() => {
eventBroadcaster.sendKeepAlive();
}, 30000);
}
}
+237
View File
@@ -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
+126
View File
@@ -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();
@@ -0,0 +1,124 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { getSQLiteClient } from '@/services/database/sqlite-client';
export interface AutoContextSettings {
autoContextEnabled: boolean;
lastPinnedMigration?: string;
}
const SETTINGS_FILE = 'settings.json';
const DEFAULT_SETTINGS: AutoContextSettings = {
autoContextEnabled: false,
};
let bootstrapAttempted = false;
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');
}
function getSettingsDir(): string {
return path.join(resolveBaseConfigDir(), 'config');
}
function getSettingsPath(): string {
return path.join(getSettingsDir(), SETTINGS_FILE);
}
function ensureSettingsDirExists(): void {
const dir = getSettingsDir();
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
function writeSettingsFile(settings: AutoContextSettings): AutoContextSettings {
ensureSettingsDirExists();
fs.writeFileSync(getSettingsPath(), JSON.stringify(settings, null, 2), 'utf-8');
return settings;
}
function bootstrapFromLegacyPins(): void {
if (bootstrapAttempted) return;
bootstrapAttempted = true;
const settingsPath = getSettingsPath();
if (fs.existsSync(settingsPath)) {
return;
}
try {
const db = getSQLiteClient();
const countRow = db
.query<{ count: number }>('SELECT COUNT(*) as count FROM nodes WHERE is_pinned = 1')
.rows[0];
const pinnedCount = Number(countRow?.count ?? 0);
if (pinnedCount > 0) {
writeSettingsFile({
autoContextEnabled: true,
lastPinnedMigration: new Date().toISOString(),
});
}
} catch (error) {
console.warn('Auto-context pin bootstrap failed:', error);
}
}
export function getAutoContextSettings(): AutoContextSettings {
bootstrapFromLegacyPins();
const settingsPath = getSettingsPath();
try {
if (!fs.existsSync(settingsPath)) {
return { ...DEFAULT_SETTINGS };
}
const raw = fs.readFileSync(settingsPath, 'utf-8');
const parsed = JSON.parse(raw);
return {
...DEFAULT_SETTINGS,
...parsed,
autoContextEnabled: Boolean(parsed?.autoContextEnabled),
};
} catch (error) {
console.warn('Failed to read auto-context settings, using defaults:', error);
return { ...DEFAULT_SETTINGS };
}
}
export function updateAutoContextSettings(
partial: Partial<AutoContextSettings>
): AutoContextSettings {
const current = getAutoContextSettings();
const next: AutoContextSettings = {
...current,
...partial,
autoContextEnabled:
typeof partial.autoContextEnabled === 'boolean'
? partial.autoContextEnabled
: current.autoContextEnabled,
};
return writeSettingsFile(next);
}
export function setAutoContextEnabled(enabled: boolean): AutoContextSettings {
return updateAutoContextSettings({ autoContextEnabled: enabled });
}
+218
View File
@@ -0,0 +1,218 @@
// API Key Storage Service
// Handles secure storage and retrieval of user-provided API keys
export interface ApiKeys {
openai?: string;
anthropic?: string;
}
export interface ApiKeyStatus {
openai: 'connected' | 'failed' | 'testing' | 'not-set';
anthropic: 'connected' | 'failed' | 'testing' | 'not-set';
}
const STORAGE_KEY = 'ra-h-api-keys';
export class ApiKeyService {
private static instance: ApiKeyService;
private keys: ApiKeys = {};
private status: ApiKeyStatus = {
openai: 'not-set',
anthropic: 'not-set'
};
static getInstance(): ApiKeyService {
if (!ApiKeyService.instance) {
ApiKeyService.instance = new ApiKeyService();
}
return ApiKeyService.instance;
}
constructor() {
this.loadKeys();
}
private notifyUpdate(): void {
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('api-keys:updated'));
}
}
// Load keys from localStorage
private loadKeys(): void {
try {
if (typeof window !== 'undefined') {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
this.keys = JSON.parse(stored);
}
}
} catch (error) {
console.warn('Failed to load API keys from storage:', error);
this.keys = {};
}
}
// Save keys to localStorage
private saveKeys(): void {
try {
if (typeof window !== 'undefined') {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.keys));
}
} catch (error) {
console.error('Failed to save API keys to storage:', error);
}
}
// Get OpenAI API key (user key or fallback to env)
getOpenAiKey(): string | undefined {
// Priority: User key > Environment key
return this.keys.openai || process.env.OPENAI_API_KEY;
}
// Get Anthropic API key (user key or fallback to env)
getAnthropicKey(): string | undefined {
// Priority: User key > Environment key
return this.keys.anthropic || process.env.ANTHROPIC_API_KEY;
}
// Set OpenAI API key
setOpenAiKey(key: string): void {
if (this.validateOpenAiKey(key)) {
this.keys.openai = key;
this.saveKeys();
this.notifyUpdate();
} else {
throw new Error('Invalid OpenAI API key format');
}
}
// Set Anthropic API key
setAnthropicKey(key: string): void {
if (this.validateAnthropicKey(key)) {
this.keys.anthropic = key;
this.saveKeys();
this.notifyUpdate();
} else {
throw new Error('Invalid Anthropic API key format');
}
}
// Clear specific key
clearOpenAiKey(): void {
delete this.keys.openai;
this.saveKeys();
this.status.openai = 'not-set';
this.notifyUpdate();
}
clearAnthropicKey(): void {
delete this.keys.anthropic;
this.saveKeys();
this.status.anthropic = 'not-set';
this.notifyUpdate();
}
// Clear all keys
clearAllKeys(): void {
this.keys = {};
this.saveKeys();
this.status = {
openai: 'not-set',
anthropic: 'not-set'
};
this.notifyUpdate();
}
// Get masked key for display (show only last 4 characters)
getMaskedKey(provider: 'openai' | 'anthropic'): string {
const key = provider === 'openai' ? this.keys.openai : this.keys.anthropic;
if (!key) return '';
return '••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••' + key.slice(-4);
}
// Check if user has provided custom keys
hasUserKeys(): boolean {
return !!(this.keys.openai || this.keys.anthropic);
}
// Get current keys (for internal use)
getStoredKeys(): ApiKeys {
return { ...this.keys };
}
// Validate OpenAI key format
private validateOpenAiKey(key: string): boolean {
return typeof key === 'string' &&
key.length > 20 &&
(key.startsWith('sk-') || key.startsWith('sk-proj-'));
}
// Validate Anthropic key format
private validateAnthropicKey(key: string): boolean {
return typeof key === 'string' &&
key.length > 20 &&
key.startsWith('sk-ant-');
}
// Test connection to OpenAI
async testOpenAiConnection(key?: string): Promise<boolean> {
const testKey = key || this.getOpenAiKey();
if (!testKey) return false;
this.status.openai = 'testing';
try {
const response = await fetch('https://api.openai.com/v1/models', {
headers: {
'Authorization': `Bearer ${testKey}`,
'Content-Type': 'application/json'
}
});
const isConnected = response.ok;
this.status.openai = isConnected ? 'connected' : 'failed';
return isConnected;
} catch (error) {
console.error('OpenAI connection test failed:', error);
this.status.openai = 'failed';
return false;
}
}
// Test connection to Anthropic
async testAnthropicConnection(key?: string): Promise<boolean> {
const testKey = key || this.getAnthropicKey();
if (!testKey) return false;
this.status.anthropic = 'testing';
try {
const response = await fetch('/api/local/test-anthropic', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ apiKey: testKey }),
});
const data = await response.json();
const isConnected = Boolean(data?.ok);
this.status.anthropic = isConnected ? 'connected' : 'failed';
return isConnected;
} catch (error) {
console.error('Anthropic connection test failed:', error);
this.status.anthropic = 'failed';
return false;
}
}
// Get connection status
getStatus(): ApiKeyStatus {
return { ...this.status };
}
// Update status
updateStatus(provider: 'openai' | 'anthropic', status: ApiKeyStatus['openai']): void {
this.status[provider] = status;
}
}
// Export singleton instance
export const apiKeyService = ApiKeyService.getInstance();
+32
View File
@@ -0,0 +1,32 @@
type Entry = { data: any; ts: number };
class ResultCache {
private store = new Map<string, Entry>();
private ttlMs = 10 * 60 * 1000; // 10 minutes
set(id: string, data: any) {
if (!id) return;
this.store.set(id, { data, ts: Date.now() });
this.gc();
}
get(id: string): any | null {
const e = this.store.get(id);
if (!e) return null;
if (Date.now() - e.ts > this.ttlMs) {
this.store.delete(id);
return null;
}
return e.data;
}
private gc() {
const now = Date.now();
for (const [k, v] of this.store.entries()) {
if (now - v.ts > this.ttlMs) this.store.delete(k);
}
}
}
export const resultCache = new ResultCache();
+277
View File
@@ -0,0 +1,277 @@
/**
* Node metadata embedding service for RA-H knowledge management system
* Embeds node metadata (title, content, dimensions, AI analysis) into nodes.embedding field
*/
import OpenAI from 'openai';
import { generateText } from 'ai';
import { createOpenAI } from '@ai-sdk/openai';
import {
createDatabaseConnection,
serializeFloat32Vector,
formatEmbeddingText,
batchProcess
} from './sqlite-vec';
interface NodeRecord {
id: number;
title: string;
content: string | null;
dimensions_json: string;
embedding?: Buffer | null;
embedding_updated_at?: string | null;
embedding_text?: string | null;
}
interface EmbedNodeOptions {
nodeId?: number;
forceReEmbed?: boolean;
verbose?: boolean;
}
export class NodeEmbedder {
private openaiClient: OpenAI;
private openaiProvider: ReturnType<typeof createOpenAI>;
private db: ReturnType<typeof createDatabaseConnection>;
private processedCount: number = 0;
private failedCount: number = 0;
constructor() {
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
throw new Error('OPENAI_API_KEY environment variable is not set');
}
this.openaiClient = new OpenAI({ apiKey });
this.openaiProvider = createOpenAI({ apiKey });
this.db = createDatabaseConnection();
}
/**
* Analyze node content with AI to extract insights
*/
private async analyzeNodeWithAI(node: NodeRecord): Promise<string> {
const dimensions = node.dimensions_json ? JSON.parse(node.dimensions_json) : [];
const dimensionsText = Array.isArray(dimensions) && dimensions.length ? dimensions.join(', ') : 'none';
const prompt = `Analyze this content and provide 2-3 key insights or themes in a concise paragraph (max 100 words):
Title: ${node.title}
Content: ${node.content || 'No content'}
Dimensions: ${dimensionsText}
Focus on the main concepts, key relationships, and practical implications.`;
try {
const { text } = await generateText({
model: this.openaiProvider('gpt-4o-mini'),
prompt,
maxOutputTokens: 150,
temperature: 0.3,
});
return text;
} catch (error) {
console.error(`AI analysis failed for node ${node.id}:`, error);
return '';
}
}
/**
* Generate embedding for text using OpenAI
*/
private async generateEmbedding(text: string): Promise<number[]> {
const response = await this.openaiClient.embeddings.create({
model: 'text-embedding-3-small',
input: text,
});
return response.data[0].embedding;
}
/**
* Embed a single node
*/
private async embedNode(node: NodeRecord, forceReEmbed: boolean = false): Promise<void> {
// Skip if already embedded and not forcing
if (node.embedding && !forceReEmbed) {
console.log(`Skipping node ${node.id} - already has embedding`);
return;
}
// Parse dimensions from JSON string
const dimensions = node.dimensions_json ? JSON.parse(node.dimensions_json) : [];
// Create base embedding text
let embeddingText = formatEmbeddingText(
node.title,
node.content || '',
dimensions
);
// Add AI analysis if content exists
if (node.content && node.content.trim().length > 0) {
const analysis = await this.analyzeNodeWithAI(node);
if (analysis) {
embeddingText += `\n\nAI Analysis: ${analysis}`;
}
}
try {
// Generate embedding
const embedding = await this.generateEmbedding(embeddingText);
const embeddingBlob = serializeFloat32Vector(embedding);
// Update database
const updateStmt = this.db.prepare(`
UPDATE nodes
SET embedding = ?,
embedding_updated_at = ?,
embedding_text = ?
WHERE id = ?
`);
const now = new Date().toISOString();
updateStmt.run(embeddingBlob, now, embeddingText, node.id);
// Update vec_nodes virtual table
try {
// Determine correct column name for primary key (node_id vs id)
// Use declared PK column from your DB schema (confirmed: node_id)
const pkCol = 'node_id';
// Delete existing entry if any
const deleteStmt = this.db.prepare(`DELETE FROM vec_nodes WHERE ${pkCol} = ?`);
deleteStmt.run(BigInt(node.id));
// Insert new entry (use bracketed string format compatible with sqlite-vec)
const vectorString = `[${embedding.join(',')}]`;
const insertStmt = this.db.prepare(`INSERT INTO vec_nodes (${pkCol}, embedding) VALUES (?, ?)`);
insertStmt.run(BigInt(node.id), vectorString);
} catch (vecError) {
console.warn(`Could not update vec_nodes for node ${node.id}:`, vecError);
// Continue - main embedding is still saved
}
this.processedCount++;
console.log(`✓ Embedded node ${node.id}: "${node.title}"`);
} catch (error) {
this.failedCount++;
console.error(`✗ Failed to embed node ${node.id}:`, error);
throw error;
}
}
/**
* Embed nodes based on options
*/
async embedNodes(options: EmbedNodeOptions = {}): Promise<{ processed: number; failed: number }> {
const { nodeId, forceReEmbed = false, verbose = false } = options;
let query: string;
let params: any[] = [];
if (nodeId) {
// Single node
query = `
SELECT n.id, n.title, n.content,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
n.embedding, n.embedding_updated_at
FROM nodes n
WHERE n.id = ?
`;
params = [nodeId];
} else if (forceReEmbed) {
// All nodes
query = `
SELECT n.id, n.title, n.content,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
n.embedding, n.embedding_updated_at
FROM nodes n
ORDER BY n.id
`;
} else {
// Only nodes without embeddings
query = `
SELECT n.id, n.title, n.content,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
n.embedding, n.embedding_updated_at
FROM nodes n
WHERE n.embedding IS NULL OR n.embedding_updated_at IS NULL
ORDER BY n.id
`;
}
const stmt = this.db.prepare(query);
const nodes = stmt.all(...params) as NodeRecord[];
if (nodes.length === 0) {
console.log('No nodes to process');
return { processed: 0, failed: 0 };
}
console.log(`Processing ${nodes.length} nodes...`);
// Process in batches
await batchProcess(
nodes,
async (node) => {
try {
await this.embedNode(node, forceReEmbed);
} catch (error) {
// Error already logged in embedNode
}
},
5, // Batch size
verbose ? (processed, total) => {
console.log(`Progress: ${processed}/${total} nodes`);
} : undefined
);
console.log(`\nComplete! Processed: ${this.processedCount}, Failed: ${this.failedCount}`);
return {
processed: this.processedCount,
failed: this.failedCount
};
}
/**
* Close database connection
*/
close(): void {
this.db.close();
}
}
/**
* CLI interface for direct execution
*/
export async function runCLI(args: string[]): Promise<void> {
const nodeId = args.includes('--node-id')
? parseInt(args[args.indexOf('--node-id') + 1])
: undefined;
const forceReEmbed = args.includes('--force');
const verbose = args.includes('--verbose');
const embedder = new NodeEmbedder();
try {
await embedder.embedNodes({ nodeId, forceReEmbed, verbose });
} finally {
embedder.close();
}
}
// Run if called directly (for testing)
if (require.main === module) {
runCLI(process.argv.slice(2)).catch(error => {
console.error('Error:', error);
process.exit(1);
});
}
+300
View File
@@ -0,0 +1,300 @@
/**
* Universal chunking and embedding service for RA-H knowledge management system
* Takes a node_id, reads chunk content from nodes table, chunks it, and stores in chunks table
*/
import OpenAI from 'openai';
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters';
import {
createDatabaseConnection,
serializeFloat32Vector,
batchProcess
} from './sqlite-vec';
interface Node {
id: number;
title: string;
chunk: string | null;
chunk_status?: string | null;
}
interface ChunkData {
content: string;
metadata: {
node_id: number;
chunk_index: number;
start_char: number;
end_char: number;
};
}
interface EmbedUniversalOptions {
nodeId: number;
verbose?: boolean;
}
export class UniversalEmbedder {
private openaiClient: OpenAI;
private db: ReturnType<typeof createDatabaseConnection>;
private textSplitter: RecursiveCharacterTextSplitter;
private vecChunksInsertSQL: string | null = null;
constructor() {
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
throw new Error('OPENAI_API_KEY environment variable is not set');
}
this.openaiClient = new OpenAI({ apiKey });
this.db = createDatabaseConnection();
// Configure text splitter (same as old KMS system)
this.textSplitter = new RecursiveCharacterTextSplitter({
chunkSize: 1000,
chunkOverlap: 200,
separators: ["\n\n", "\n", ". ", " ", ""],
});
}
/**
* Determine correct insert SQL for vec_chunks based on actual schema
*/
private resolveVecChunksInsertSQL(): string {
// Use declared PK column from your DB schema (confirmed: chunk_id)
if (!this.vecChunksInsertSQL) {
this.vecChunksInsertSQL = 'INSERT OR REPLACE INTO vec_chunks (chunk_id, embedding) VALUES (?, ?)';
}
return this.vecChunksInsertSQL;
}
/**
* Generate embedding for text using OpenAI
*/
private async generateEmbedding(text: string): Promise<number[]> {
const response = await this.openaiClient.embeddings.create({
model: 'text-embedding-3-small',
input: text,
});
return response.data[0].embedding;
}
/**
* Delete existing chunks for a node
*/
private deleteExistingChunks(nodeId: number): void {
// First, get all chunk IDs for this node
const chunkIds = this.db.prepare('SELECT id FROM chunks WHERE node_id = ?').all(nodeId) as Array<{ id: number }>;
// Delete from vec_chunks first, one by one to ensure they're removed
for (const chunk of chunkIds) {
try {
const deleteVecStmt = this.db.prepare('DELETE FROM vec_chunks WHERE chunk_id = ?');
deleteVecStmt.run(BigInt(chunk.id));
} catch (error) {
console.warn(`Could not delete vec_chunk ${chunk.id}:`, error);
}
}
// Then delete from chunks table
const deleteChunksStmt = this.db.prepare('DELETE FROM chunks WHERE node_id = ?');
deleteChunksStmt.run(nodeId);
}
/**
* Store a chunk with its embedding
*/
private async storeChunk(
nodeId: number,
chunkContent: string,
chunkIndex: number,
metadata: any
): Promise<void> {
// Generate embedding
const embedding = await this.generateEmbedding(chunkContent);
// Insert into chunks table (align with existing schema)
const insertStmt = this.db.prepare(`
INSERT INTO chunks (node_id, chunk_idx, text, embedding_type, metadata, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`);
const now = new Date().toISOString();
const result = insertStmt.run(
nodeId,
chunkIndex,
chunkContent,
'text-embedding-3-small',
JSON.stringify(metadata),
now
);
const chunkId = Number(result.lastInsertRowid);
// Insert into vec_chunks virtual table (use bracketed string format)
try {
const vectorString = `[${embedding.join(',')}]`;
// First try to delete any existing vec_chunk with this ID
try {
const deleteStmt = this.db.prepare('DELETE FROM vec_chunks WHERE chunk_id = ?');
deleteStmt.run(BigInt(chunkId));
} catch {}
// Now insert the new embedding
const sql = 'INSERT INTO vec_chunks (chunk_id, embedding) VALUES (?, ?)';
const vecInsertStmt = this.db.prepare(sql);
vecInsertStmt.run(BigInt(chunkId), vectorString);
} catch (error) {
console.warn(`Could not insert into vec_chunks for chunk ${chunkId}:`, error);
}
}
/**
* Process a single node for chunking and embedding
*/
async processNode(options: EmbedUniversalOptions): Promise<{ chunks: number }> {
const { nodeId, verbose = false } = options;
// Get node data
const stmt = this.db.prepare(`
SELECT id, title, chunk, chunk_status
FROM nodes
WHERE id = ?
`);
const node = stmt.get(nodeId) as Node | undefined;
if (!node) {
throw new Error(`Node ${nodeId} not found`);
}
if (!node.chunk || node.chunk.trim().length === 0) {
console.log(`Node ${nodeId} has no chunk content to process`);
return { chunks: 0 };
}
console.log(`Processing node ${nodeId}: "${node.title}"`);
// Delete existing chunks
this.deleteExistingChunks(nodeId);
// Split text into chunks
const chunks = await this.textSplitter.splitText(node.chunk);
if (verbose) {
console.log(`Split into ${chunks.length} chunks`);
}
// Process each chunk
let startChar = 0;
await batchProcess(
chunks.map((chunkContent, index) => ({ chunkContent, index })),
async ({ chunkContent, index }) => {
const endChar = startChar + chunkContent.length;
const metadata = {
node_id: nodeId,
chunk_index: index,
start_char: startChar,
end_char: endChar,
title: node.title,
};
await this.storeChunk(nodeId, chunkContent, index, metadata);
if (verbose) {
console.log(` Chunk ${index + 1}/${chunks.length}: ${chunkContent.substring(0, 50)}...`);
}
startChar = endChar;
},
5, // Batch size
verbose ? (processed, total) => {
console.log(`Embedding progress: ${processed}/${total} chunks`);
} : undefined
);
// Update node chunk_status
const updateStmt = this.db.prepare(`
UPDATE nodes
SET chunk_status = 'chunked'
WHERE id = ?
`);
updateStmt.run(nodeId);
console.log(`✓ Created ${chunks.length} chunks for node ${nodeId}`);
return { chunks: chunks.length };
}
/**
* Get statistics about chunks in the database
*/
getStats(): { totalChunks: number; totalNodes: number; avgChunksPerNode: number } {
const statsStmt = this.db.prepare(`
SELECT
COUNT(DISTINCT node_id) as total_nodes,
COUNT(*) as total_chunks
FROM chunks
`);
const stats = statsStmt.get() as any;
return {
totalChunks: stats.total_chunks || 0,
totalNodes: stats.total_nodes || 0,
avgChunksPerNode: stats.total_nodes > 0
? Math.round(stats.total_chunks / stats.total_nodes * 10) / 10
: 0
};
}
/**
* Close database connection
*/
close(): void {
this.db.close();
}
}
/**
* CLI interface for direct execution
*/
export async function runCLI(args: string[]): Promise<void> {
if (!args.includes('--node-id')) {
console.error('Error: --node-id is required');
process.exit(1);
}
const nodeId = parseInt(args[args.indexOf('--node-id') + 1]);
const verbose = args.includes('--verbose');
if (isNaN(nodeId)) {
console.error('Error: Invalid node ID');
process.exit(1);
}
const embedder = new UniversalEmbedder();
try {
const result = await embedder.processNode({ nodeId, verbose });
if (verbose) {
const stats = embedder.getStats();
console.log('\nDatabase statistics:');
console.log(` Total chunks: ${stats.totalChunks}`);
console.log(` Total nodes with chunks: ${stats.totalNodes}`);
console.log(` Average chunks per node: ${stats.avgChunksPerNode}`);
}
} finally {
embedder.close();
}
}
// Run if called directly (for testing)
if (require.main === module) {
runCLI(process.argv.slice(2)).catch(error => {
console.error('Error:', error);
process.exit(1);
});
}
+394
View File
@@ -0,0 +1,394 @@
/**
* PDF/Paper content extraction for RA-H knowledge management system
* Extracts text content from PDF files and returns formatted content
*/
// Import pdf-parse directly from lib to avoid index.js debug side effects
// (pdf-parse/index.js conditionally reads ./test/data/05-versions-space.pdf when module.parent is falsy in some bundles)
// See: node_modules/pdf-parse/index.js
// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports
const pdf = require('pdf-parse/lib/pdf-parse.js');
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
interface PaperMetadata {
title?: string;
pages: number;
info?: any;
text_length: number;
filename?: string;
extraction_method?: string;
}
interface ExtractionResult {
content: string;
chunk: string;
metadata: PaperMetadata;
url: string;
}
type PdfJsTextItem = {
str?: string;
};
type PdfMetadataInfo = Record<string, unknown>;
export class PaperExtractor {
private headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
};
private isLikelyPdf(buffer: Buffer): boolean {
const header = buffer.slice(0, 8).toString('utf8');
return header.includes('%PDF');
}
/**
* Clean academic PDF content to reduce citation fragments and corrupted text
*/
private cleanAcademicContent(content: string): string {
const lines = content.split('\n');
const cleanedLines: string[] = [];
for (let line of lines) {
line = line.trim();
// Skip empty lines for now
if (line.length === 0) {
continue;
}
// Skip lines that look like headers/footers (page numbers, dates)
if (/^[\d\s\-\/]+$/.test(line)) {
continue;
}
// Skip lines that are just citations like "[1]" or "(Smith, 2020)"
if (/^\[\d+\]$/.test(line) || /^\([^)]+,\s*\d{4}\)$/.test(line)) {
continue;
}
// Skip very short lines that are likely artifacts
if (line.length < 3) {
continue;
}
// Skip lines that are mostly special characters (likely corrupted)
const specialCharCount = (line.match(/[^\w\s]/g) || []).length;
if (specialCharCount > line.length * 0.5) {
continue;
}
cleanedLines.push(line);
}
// Combine lines into paragraphs
const paragraphs: string[] = [];
let currentParagraph = '';
for (const line of cleanedLines) {
// Check if line looks like it ends a sentence
const endsWithPunctuation = /[.!?]$/.test(line);
// Check if next line starts with capital or is a heading
const looksLikeNewParagraph = /^[A-Z0-9]/.test(line) && currentParagraph.endsWith('.');
if (currentParagraph.length === 0) {
currentParagraph = line;
} else if (looksLikeNewParagraph) {
paragraphs.push(currentParagraph);
currentParagraph = line;
} else if (endsWithPunctuation) {
currentParagraph += ' ' + line;
} else {
currentParagraph += ' ' + line;
}
}
if (currentParagraph.length > 0) {
paragraphs.push(currentParagraph);
}
// Filter out very short paragraphs (likely noise)
return paragraphs.filter(p => p.length > 30).join('\n\n');
}
/**
* Download PDF from URL to temporary file
*/
private async downloadPDF(url: string): Promise<string> {
const response = await fetch(url, {
headers: this.headers,
signal: AbortSignal.timeout(60000), // 60 second timeout for PDFs
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// Create temp file
const tempDir = os.tmpdir();
const tempFile = path.join(tempDir, `pdf_${Date.now()}.pdf`);
// Get PDF data as buffer
const buffer = Buffer.from(await response.arrayBuffer());
// Write to temp file
await fs.writeFile(tempFile, buffer);
return tempFile;
}
/**
* Extract text from PDF buffer
*/
private async extractFromPDF(buffer: Buffer): Promise<{ text: string; metadata: PaperMetadata }> {
try {
const data = await pdf(buffer);
const metadata: PaperMetadata = {
pages: data.numpages,
info: data.info,
text_length: data.text.length,
};
// Try to extract title from metadata or first lines
if (data.info && data.info.Title) {
metadata.title = data.info.Title;
} else {
// Try to get title from first few lines
const firstLines = data.text.split('\n').slice(0, 5);
const possibleTitle = firstLines.find((line: string) =>
line.length > 10 && line.length < 200 && /[A-Z]/.test(line)
);
if (possibleTitle) {
metadata.title = possibleTitle.trim();
}
}
return {
text: data.text,
metadata,
};
} catch (error: unknown) {
const primaryMessage = this.formatErrorMessage(error);
console.warn('Primary pdf-parse extraction failed, attempting pdfjs-dist fallback:', primaryMessage);
try {
return await this.extractWithPdfJs(buffer);
} catch (fallbackError: unknown) {
const fallbackMessage = this.formatErrorMessage(fallbackError);
throw new Error(`PDF parsing failed: ${primaryMessage}; fallback error: ${fallbackMessage}`);
}
}
}
private async extractWithPdfJs(buffer: Buffer): Promise<{ text: string; metadata: PaperMetadata }> {
const pdfjsLib = await import('pdfjs-dist/legacy/build/pdf.mjs');
const loadingTask = pdfjsLib.getDocument({ data: buffer, useSystemFonts: true });
const doc = await loadingTask.promise;
let aggregatedText = '';
for (let pageIndex = 1; pageIndex <= doc.numPages; pageIndex++) {
const page = await doc.getPage(pageIndex);
const textContent = await page.getTextContent();
const pageText = (textContent.items as PdfJsTextItem[])
.map((item) => (typeof item?.str === 'string' ? item.str : ''))
.join(' ')
.replace(/\s+/g, ' ')
.trim();
if (pageText) {
aggregatedText += pageText + '\n\n';
}
}
let info: PdfMetadataInfo = {};
try {
const metadata = await doc.getMetadata();
info = (metadata?.info as PdfMetadataInfo) || {};
} catch (metadataError: unknown) {
console.warn('pdfjs-dist metadata extraction failed:', metadataError);
}
const metadata: PaperMetadata = {
pages: doc.numPages,
info,
text_length: aggregatedText.length,
extraction_method: 'typescript_pdfjs_dist'
};
const title = typeof info['Title'] === 'string' ? (info['Title'] as string) : undefined;
if (!metadata.title && title) {
metadata.title = title;
}
return {
text: aggregatedText,
metadata,
};
}
/**
* Format content for node creation
*/
private formatContent(metadata: PaperMetadata, text: string): string {
const sections: string[] = [];
// Add metadata section
sections.push('## Document Information');
if (metadata.title) {
sections.push(`**Title:** ${metadata.title}`);
}
sections.push(`**Pages:** ${metadata.pages}`);
sections.push(`**Text Length:** ${metadata.text_length} characters`);
if (metadata.info) {
if (metadata.info.Author) {
sections.push(`**Author:** ${metadata.info.Author}`);
}
if (metadata.info.Creator) {
sections.push(`**Creator:** ${metadata.info.Creator}`);
}
if (metadata.info.Producer) {
sections.push(`**Producer:** ${metadata.info.Producer}`);
}
if (metadata.info.CreationDate) {
sections.push(`**Creation Date:** ${metadata.info.CreationDate}`);
}
}
sections.push('');
// Add content
sections.push('## Content');
sections.push(text);
return sections.join('\n');
}
/**
* Main extraction method
*/
async extract(url: string): Promise<ExtractionResult> {
let tempFile: string | null = null;
try {
// Validate URL
if (!url.startsWith('http://') && !url.startsWith('https://')) {
throw new Error('Invalid URL format - must start with http:// or https://');
}
// Check if URL looks like it points to a PDF
const urlLower = url.toLowerCase();
if (!urlLower.includes('.pdf') && !urlLower.includes('arxiv.org')) {
console.warn('Warning: URL does not appear to point to a PDF file');
}
// Download PDF
tempFile = await this.downloadPDF(url);
// Read PDF buffer
const buffer = await fs.readFile(tempFile);
if (!this.isLikelyPdf(buffer)) {
const preview = buffer.slice(0, 64).toString('utf8');
throw new Error(`Downloaded file does not appear to be a PDF (header: ${preview})`);
}
// Extract text and metadata
const { text, metadata } = await this.extractFromPDF(buffer);
// Clean the text
const cleanedText = this.cleanAcademicContent(text);
// Add filename to metadata
metadata.filename = path.basename(url);
// Mark extraction method for downstream metadata
if (!metadata.extraction_method) {
metadata.extraction_method = 'typescript_pdf-parse';
}
// Format content for display
const content = this.formatContent(metadata, cleanedText);
// Chunk is the cleaned text
const chunk = cleanedText;
return {
content,
chunk,
metadata,
url,
};
} catch (error: unknown) {
if (error instanceof Error && error.name === 'AbortError') {
throw new Error('Request timeout - PDF download took too long');
}
throw error;
} finally {
// Clean up temp file
if (tempFile) {
try {
await fs.unlink(tempFile);
} catch (cleanupError: unknown) {
console.warn('Could not delete temp file:', tempFile, cleanupError);
}
}
}
}
private formatErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
if (typeof error === 'string') {
return error;
}
try {
return JSON.stringify(error);
} catch {
return 'Unknown error';
}
}
}
/**
* Standalone extraction function for direct use
*/
export async function extractPaper(url: string): Promise<ExtractionResult> {
const extractor = new PaperExtractor();
return extractor.extract(url);
}
/**
* CLI interface for direct execution
*/
export async function runCLI(args: string[]): Promise<void> {
if (args.length === 0) {
console.error('Usage: paper-extract <url>');
process.exit(1);
}
const url = args[0];
try {
const result = await extractPaper(url);
// Output as JSON for compatibility with existing tools
console.log(JSON.stringify(result, null, 2));
} catch (error: any) {
console.error('Error:', error.message);
process.exit(1);
}
}
// Run if called directly (for testing)
if (require.main === module) {
runCLI(process.argv.slice(2)).catch(error => {
console.error('Error:', error);
process.exit(1);
});
}
@@ -0,0 +1,280 @@
/**
* Website content extraction for RA-H knowledge management system
* Extracts text content from web pages and returns formatted content
*/
import * as cheerio from 'cheerio';
interface WebsiteMetadata {
title: string;
author?: string;
date?: string;
description?: string;
og_image?: string;
site_name?: string;
extraction_method?: string;
}
interface ExtractionResult {
content: string;
chunk: string;
metadata: WebsiteMetadata;
url: string;
}
export class WebsiteExtractor {
private headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
};
/**
* Clean extracted content for better readability
*/
private cleanContent(content: string): string {
// Remove excessive whitespace
content = content.replace(/\s+/g, ' ').trim();
// Remove cookie/privacy policy mentions
content = content.replace(/cookie\s+policy|privacy\s+policy|terms\s+of\s+service/gi, '');
// Split into paragraphs and clean
const paragraphs = content
.split('\n')
.map(p => p.trim())
.filter(p => p.length > 20); // Remove very short paragraphs (likely nav/UI)
return paragraphs.join('\n\n');
}
/**
* Extract metadata from HTML
*/
private extractMetadata($: cheerio.CheerioAPI): WebsiteMetadata {
const metadata: WebsiteMetadata = {
title: '',
};
// Title extraction (priority order)
metadata.title =
$('meta[property="og:title"]').attr('content') ||
$('meta[name="twitter:title"]').attr('content') ||
$('title').text() ||
$('h1').first().text() ||
'Untitled';
// Author extraction
metadata.author =
$('meta[name="author"]').attr('content') ||
$('meta[property="article:author"]').attr('content') ||
$('.author').first().text() ||
$('[rel="author"]').first().text() ||
undefined;
// Date extraction
metadata.date =
$('meta[property="article:published_time"]').attr('content') ||
$('meta[name="publish_date"]').attr('content') ||
$('time').first().attr('datetime') ||
$('.date').first().text() ||
undefined;
// Description
metadata.description =
$('meta[property="og:description"]').attr('content') ||
$('meta[name="description"]').attr('content') ||
undefined;
// Image
metadata.og_image =
$('meta[property="og:image"]').attr('content') ||
undefined;
// Site name
metadata.site_name =
$('meta[property="og:site_name"]').attr('content') ||
undefined;
return metadata;
}
/**
* Extract main content from HTML
*/
private extractMainContent($: cheerio.CheerioAPI): string {
// Remove script and style elements
$('script, style, noscript').remove();
// Remove common navigation and footer elements
$('nav, header, footer, aside, .nav, .header, .footer, .sidebar, .menu, .advertisement').remove();
// Try to find main content areas (in priority order)
const contentSelectors = [
'main',
'article',
'[role="main"]',
'.content',
'.post',
'.article-body',
'.entry-content',
'#content',
'.container',
'body',
];
let mainContent = '';
for (const selector of contentSelectors) {
const element = $(selector).first();
if (element.length > 0) {
// Extract text from paragraphs, headings, lists
const textElements = element.find('p, h1, h2, h3, h4, h5, h6, li, blockquote, td, th');
if (textElements.length > 0) {
const texts: string[] = [];
textElements.each((_, el) => {
const text = $(el).text().trim();
if (text.length > 0) {
texts.push(text);
}
});
if (texts.length > 0) {
mainContent = texts.join('\n\n');
break;
}
}
}
}
// Fallback to all text if no main content found
if (!mainContent) {
mainContent = $('body').text();
}
return this.cleanContent(mainContent);
}
/**
* Format content for node creation
*/
private formatContent(metadata: WebsiteMetadata, mainContent: string): string {
const sections: string[] = [];
// Add metadata section
sections.push('## Article Information');
sections.push(`**Title:** ${metadata.title}`);
if (metadata.author) {
sections.push(`**Author:** ${metadata.author}`);
}
if (metadata.date) {
sections.push(`**Date:** ${metadata.date}`);
}
if (metadata.site_name) {
sections.push(`**Source:** ${metadata.site_name}`);
}
if (metadata.description) {
sections.push(`**Description:** ${metadata.description}`);
}
sections.push('');
// Add main content
sections.push('## Content');
sections.push(mainContent);
return sections.join('\n');
}
/**
* Main extraction method
*/
async extract(url: string): Promise<ExtractionResult> {
// Validate URL
if (!url.startsWith('http://') && !url.startsWith('https://')) {
throw new Error('Invalid URL format - must start with http:// or https://');
}
try {
// Fetch the webpage
const response = await fetch(url, {
headers: this.headers,
signal: AbortSignal.timeout(30000), // 30 second timeout
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const html = await response.text();
// Parse HTML with cheerio
const $ = cheerio.load(html);
// Extract metadata and content
const metadata = this.extractMetadata($);
// Mark extraction method for downstream metadata
metadata.extraction_method = 'typescript_cheerio';
const mainContent = this.extractMainContent($);
// Format content for display
const content = this.formatContent(metadata, mainContent);
// Chunk is the main content text
const chunk = mainContent;
return {
content,
chunk,
metadata,
url,
};
} catch (error: any) {
if (error.name === 'AbortError') {
throw new Error('Request timeout - website took too long to respond');
}
throw error;
}
}
}
/**
* Standalone extraction function for direct use
*/
export async function extractWebsite(url: string): Promise<ExtractionResult> {
const extractor = new WebsiteExtractor();
return extractor.extract(url);
}
/**
* CLI interface for direct execution
*/
export async function runCLI(args: string[]): Promise<void> {
if (args.length === 0) {
console.error('Usage: website-extract <url>');
process.exit(1);
}
const url = args[0];
try {
const result = await extractWebsite(url);
// Output as JSON for compatibility with existing tools
console.log(JSON.stringify(result, null, 2));
} catch (error: any) {
console.error('Error:', error.message);
process.exit(1);
}
}
// Run if called directly (for testing)
if (require.main === module) {
runCLI(process.argv.slice(2)).catch(error => {
console.error('Error:', error);
process.exit(1);
});
}
@@ -0,0 +1,235 @@
/**
* YouTube content extraction for RA-H knowledge management system
* Uses youtube-transcript npm package - more similar to Python youtube-transcript-api
*/
import { YoutubeTranscript } from 'youtube-transcript';
interface TranscriptSegment {
text: string;
start: number;
duration: number;
}
interface YouTubeMetadata {
video_id: string;
video_url: string;
video_title: string;
channel_name: string;
channel_url: string;
thumbnail_url: string;
source_type: string;
transcript_length: number;
total_segments: number;
content_format: string;
language?: string;
provider: string;
extraction_method: string;
}
interface ExtractionResult {
success: boolean;
content: string;
chunk: string; // Same as content, but tool expects this field name
metadata: YouTubeMetadata;
error?: string;
}
export class YouTubeExtractor {
/**
* Extract video ID from YouTube URL
*/
private extractVideoId(url: string): string | null {
if (!url) return null;
if (url.includes('youtu.be')) {
return url.split('/').pop()?.split('?')[0] || null;
} else if (url.includes('youtube.com/watch')) {
const urlParams = new URLSearchParams(url.split('?')[1]);
return urlParams.get('v');
} else if (url.includes('youtube.com/live')) {
return url.split('/live/')[1]?.split('?')[0] || null;
} else if (url.includes('youtube.com/embed')) {
return url.split('/embed/')[1]?.split('?')[0] || null;
} else if (url.includes('youtube.com/v')) {
return url.split('/v/')[1]?.split('?')[0] || null;
}
return null;
}
/**
* Get video metadata from YouTube oEmbed API
*/
private async getVideoMetadata(url: string): Promise<{ title: string; author_name: string; author_url: string; thumbnail_url: string }> {
try {
const oembedUrl = `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`;
const response = await fetch(oembedUrl, {
signal: AbortSignal.timeout(10000)
});
if (response.ok) {
const data = await response.json();
return {
title: data.title || 'YouTube Video',
author_name: data.author_name || 'Unknown Channel',
author_url: data.author_url || '',
thumbnail_url: data.thumbnail_url || ''
};
}
} catch (error) {
console.error('oEmbed extraction failed:', error);
}
// Fallback metadata
const videoId = this.extractVideoId(url);
return {
title: `YouTube Video ${videoId || 'Unknown'}`,
author_name: 'Unknown Channel',
author_url: '',
thumbnail_url: ''
};
}
/**
* Get transcript using youtube-transcript npm package (like Python approach)
*/
private async getTranscript(url: string): Promise<{ transcript: string; segments: TranscriptSegment[]; language?: string }> {
try {
// Get transcript using npm package (more similar to Python approach)
const transcriptData = await YoutubeTranscript.fetchTranscript(url);
if (!transcriptData || transcriptData.length === 0) {
throw new Error('No transcript segments found');
}
// Convert to our format
const segments: TranscriptSegment[] = transcriptData.map(item => ({
text: item.text,
start: item.offset / 1000, // Convert ms to seconds
duration: item.duration ? item.duration / 1000 : 0 // Convert ms to seconds
}));
// Format with timestamps like Python version
const formattedSegments: string[] = [];
for (const segment of segments) {
formattedSegments.push(`[${segment.start.toFixed(1)}s] ${segment.text}`);
}
const fullTranscript = formattedSegments.join('\n');
return {
transcript: fullTranscript,
segments,
language: 'en'
};
} catch (error: any) {
throw new Error(`Failed to extract YouTube transcript: ${error.message}`);
}
}
/**
* Main extraction method - uses youtube-transcript npm package like Python script uses youtube-transcript-api
*/
async extract(url: string): Promise<ExtractionResult> {
try {
// Validate URL
if (!url.includes('youtube.com') && !url.includes('youtu.be')) {
throw new Error('Invalid YouTube URL');
}
const videoId = this.extractVideoId(url);
if (!videoId) {
throw new Error('Could not extract video ID from URL');
}
// Get video metadata
const videoMetadata = await this.getVideoMetadata(url);
// Get transcript using npm package (similar to Python approach)
const { transcript, segments, language } = await this.getTranscript(url);
// Create metadata matching Python version exactly
const metadata: YouTubeMetadata = {
video_id: videoId,
video_url: url,
video_title: videoMetadata.title,
channel_name: videoMetadata.author_name,
channel_url: videoMetadata.author_url,
thumbnail_url: videoMetadata.thumbnail_url,
source_type: 'youtube_transcript',
transcript_length: transcript.length,
total_segments: segments.length,
content_format: 'timestamped_transcript',
language: language || 'unknown',
provider: 'YouTube',
extraction_method: 'typescript_npm_youtube_transcript'
};
return {
success: true,
content: transcript,
chunk: transcript, // Tool expects this field
metadata
};
} catch (error: any) {
return {
success: false,
content: '',
chunk: '', // Tool expects this field
metadata: {} as YouTubeMetadata,
error: error.message
};
}
}
}
/**
* Main function for command line usage (matching Python interface)
*/
export async function main(url: string): Promise<ExtractionResult> {
const extractor = new YouTubeExtractor();
return extractor.extract(url);
}
/**
* Standalone extraction function for direct use
*/
export async function extractYouTube(url: string): Promise<ExtractionResult> {
const extractor = new YouTubeExtractor();
return extractor.extract(url);
}
/**
* CLI interface for direct execution (matching Python interface)
*/
export async function runCLI(): Promise<void> {
if (process.argv.length !== 3) {
console.log(JSON.stringify({
success: false,
error: "Usage: node youtube.js <youtube_url>"
}));
process.exit(1);
}
const url = process.argv[2];
const result = await main(url);
console.log(JSON.stringify(result));
if (!result.success) {
process.exit(1);
}
}
// Run if called directly (for testing)
if (require.main === module) {
runCLI().catch(error => {
console.log(JSON.stringify({
success: false,
error: error.message
}));
process.exit(1);
});
}
@@ -0,0 +1,288 @@
/**
* YouTube content extraction for RA-H knowledge management system
* Uses innertube-based transcript extraction via youtube-transcript-plus
* Falls back to the legacy npm extractor if captions cannot be retrieved
*/
import {
fetchTranscript as fetchTranscriptPlus,
YoutubeTranscriptNotAvailableLanguageError,
} from 'youtube-transcript-plus';
import { extractYouTube as extractYouTubeNpm } from './youtube-npm';
interface TranscriptSegment {
text: string;
start: number;
duration: number;
}
interface YouTubeMetadata {
video_id: string;
video_url: string;
video_title: string;
channel_name: string;
channel_url: string;
thumbnail_url: string;
source_type: string;
transcript_length: number;
total_segments: number;
content_format: string;
language?: string;
provider: string;
extraction_method: string;
}
interface ExtractionResult {
success: boolean;
content: string;
chunk: string;
metadata: YouTubeMetadata;
error?: string;
}
export class YouTubeExtractor {
private decodeHtmlEntities(input: string): string {
if (!input) {
return '';
}
let result = input.replace(/&amp;/g, '&');
result = result.replace(/&#(\d+);/g, (_match, dec) => {
const code = Number.parseInt(dec, 10);
return Number.isNaN(code) ? _match : String.fromCharCode(code);
});
result = result
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&lsquo;/g, "'")
.replace(/&rsquo;/g, "'")
.replace(/&ldquo;/g, '"')
.replace(/&rdquo;/g, '"')
.replace(/&nbsp;/g, ' ')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>');
return result;
}
private formatSegments(segments: TranscriptSegment[]): string {
return segments
.map((segment) => {
const startTime = Number.isFinite(segment.start) ? segment.start : 0;
return `[${startTime.toFixed(1)}s] ${segment.text}`;
})
.join('\n');
}
private extractVideoId(url: string): string | null {
if (!url) return null;
if (url.includes('youtu.be')) {
return url.split('/').pop()?.split('?')[0] || null;
} else if (url.includes('youtube.com/watch')) {
const urlParams = new URLSearchParams(url.split('?')[1]);
return urlParams.get('v');
} else if (url.includes('youtube.com/live')) {
return url.split('/live/')[1]?.split('?')[0] || null;
} else if (url.includes('youtube.com/embed')) {
return url.split('/embed/')[1]?.split('?')[0] || null;
} else if (url.includes('youtube.com/v')) {
return url.split('/v/')[1]?.split('?')[0] || null;
}
return null;
}
private async getVideoMetadata(url: string): Promise<{ title: string; author_name: string; author_url: string; thumbnail_url: string }> {
try {
const oembedUrl = `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`;
const response = await fetch(oembedUrl, {
signal: AbortSignal.timeout(10000)
});
if (response.ok) {
const data = await response.json();
return {
title: data.title || 'YouTube Video',
author_name: data.author_name || 'Unknown Channel',
author_url: data.author_url || '',
thumbnail_url: data.thumbnail_url || ''
};
}
} catch (error) {
console.error('oEmbed extraction failed:', error);
}
const videoId = this.extractVideoId(url);
return {
title: `YouTube Video ${videoId || 'Unknown'}`,
author_name: 'Unknown Channel',
author_url: '',
thumbnail_url: ''
};
}
private async fetchPrimaryTranscript(url: string): Promise<{
transcript: string;
segments: TranscriptSegment[];
language?: string;
extractionMethod: string;
}> {
const attempts: Array<{ lang?: string; label: string }> = [
{ lang: 'en', label: 'typescript_youtube_transcript_plus_en' },
{ label: 'typescript_youtube_transcript_plus' },
];
let lastError: unknown = null;
for (const attempt of attempts) {
try {
const entries = await fetchTranscriptPlus(url, attempt.lang ? { lang: attempt.lang } : undefined);
const language = entries.find((entry) => entry.lang)?.lang;
const segments = entries
.map((entry) => ({
text: this.decodeHtmlEntities(entry.text ?? '').trim(),
start: Number(entry.offset ?? 0),
duration: Number(entry.duration ?? 0),
}))
.filter((segment) => segment.text.length > 0);
if (segments.length === 0) {
throw new Error('Transcript returned no segments');
}
const transcript = this.formatSegments(segments);
return {
transcript,
segments,
language,
extractionMethod: attempt.label,
};
} catch (error) {
lastError = error;
if (attempt.lang && error instanceof YoutubeTranscriptNotAvailableLanguageError) {
continue;
}
throw error;
}
}
throw lastError instanceof Error ? lastError : new Error('Unable to fetch transcript');
}
private formatErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
if (typeof error === 'string') {
return error;
}
try {
return JSON.stringify(error);
} catch {
return 'Unknown error';
}
}
async extract(url: string): Promise<ExtractionResult> {
try {
if (!url.includes('youtube.com') && !url.includes('youtu.be')) {
throw new Error('Invalid YouTube URL');
}
const videoId = this.extractVideoId(url);
if (!videoId) {
throw new Error('Could not extract video ID from URL');
}
const { transcript, segments, language, extractionMethod } = await this.fetchPrimaryTranscript(url);
const videoMetadata = await this.getVideoMetadata(url);
const metadata: YouTubeMetadata = {
video_id: videoId,
video_url: url,
video_title: videoMetadata.title,
channel_name: videoMetadata.author_name,
channel_url: videoMetadata.author_url,
thumbnail_url: videoMetadata.thumbnail_url,
source_type: 'youtube_transcript',
transcript_length: transcript.length,
total_segments: segments.length,
content_format: 'timestamped_transcript',
language: language || 'unknown',
provider: 'YouTube',
extraction_method: extractionMethod,
};
return {
success: true,
content: transcript,
chunk: transcript,
metadata,
};
} catch (error: unknown) {
try {
return await this.runNpmFallback(url);
} catch (fallbackError: unknown) {
return {
success: false,
content: '',
chunk: '',
metadata: {} as YouTubeMetadata,
error: `${this.formatErrorMessage(error)}; fallback error: ${this.formatErrorMessage(fallbackError)}`,
};
}
}
}
private async runNpmFallback(url: string): Promise<ExtractionResult> {
console.warn('Primary transcript extraction failed; falling back to legacy youtube-transcript package');
const fallback = await extractYouTubeNpm(url);
return {
success: fallback.success,
content: fallback.content,
chunk: fallback.chunk,
metadata: fallback.metadata as YouTubeMetadata,
error: fallback.error,
};
}
}
export async function main(url: string): Promise<ExtractionResult> {
const extractor = new YouTubeExtractor();
return extractor.extract(url);
}
export async function extractYouTube(url: string): Promise<ExtractionResult> {
const extractor = new YouTubeExtractor();
return extractor.extract(url);
}
export async function runCLI(): Promise<void> {
if (process.argv.length !== 3) {
console.log(JSON.stringify({
success: false,
error: "Usage: node youtube.js <youtube_url>"
}));
process.exit(1);
}
const url = process.argv[2];
const result = await main(url);
console.log(JSON.stringify(result));
if (!result.success) {
process.exit(1);
}
}
if (require.main === module) {
runCLI().catch(error => {
console.log(JSON.stringify({
success: false,
error: error.message
}));
process.exit(1);
});
}
+114
View File
@@ -0,0 +1,114 @@
/**
* SQLite vec0 utilities for TypeScript
* Handles binary serialization for vec0 BLOB storage
*/
import Database from 'better-sqlite3';
import path from 'path';
import os from 'os';
/**
* Serialize a float array to binary format for vec0 storage
* Equivalent to Python's struct.pack(f'{len(vector)}f', *vector)
*/
export function serializeFloat32Vector(vector: number[]): Buffer {
const buffer = Buffer.allocUnsafe(vector.length * 4);
for (let i = 0; i < vector.length; i++) {
buffer.writeFloatLE(vector[i], i * 4);
}
return buffer;
}
/**
* Deserialize a vec0 BLOB back to float array
*/
export function deserializeFloat32Vector(blob: Buffer): number[] {
const vector: number[] = [];
for (let i = 0; i < blob.length; i += 4) {
vector.push(blob.readFloatLE(i));
}
return vector;
}
/**
* Get SQLite database path from environment or default location
*/
export function getDatabasePath(): string {
const envPath = process.env.SQLITE_DB_PATH;
if (envPath) {
return envPath;
}
// Default path: ~/Library/Application Support/RA-H/db/rah.sqlite
const homeDir = os.homedir();
return path.join(homeDir, 'Library', 'Application Support', 'RA-H', 'db', 'rah.sqlite');
}
/**
* Get vec extension path from environment or default location
*/
export function getVecExtensionPath(): string {
const envPath = process.env.SQLITE_VEC_EXTENSION_PATH;
if (envPath) {
return envPath;
}
// Default path relative to project root
return path.join(process.cwd(), 'vendor', 'sqlite-extensions', 'vec0.dylib');
}
/**
* Create database connection with vec0 extension loaded
*/
export function createDatabaseConnection(): Database.Database {
const dbPath = getDatabasePath();
const vecPath = getVecExtensionPath();
const db = new Database(dbPath);
// Load vec0 extension
try {
db.loadExtension(vecPath);
} catch (error) {
console.error('Warning: Could not load vec0 extension:', error);
// Continue without vector support for non-vector operations
}
return db;
}
/**
* Format embedding text for node metadata
*/
export function formatEmbeddingText(
title: string,
content: string,
dimensions: string[]
): string {
const dimensionsText = dimensions.length > 0 ? dimensions.join(', ') : 'none';
return `Title: ${title}\n\nContent: ${content}\n\nDimensions: ${dimensionsText}`;
}
/**
* Batch process items with progress logging
*/
export async function batchProcess<T, R>(
items: T[],
processor: (item: T) => Promise<R>,
batchSize: number = 10,
onProgress?: (processed: number, total: number) => void
): Promise<R[]> {
const results: R[] = [];
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, Math.min(i + batchSize, items.length));
const batchResults = await Promise.all(batch.map(processor));
results.push(...batchResults);
if (onProgress) {
onProgress(Math.min(i + batchSize, items.length), items.length);
}
}
return results;
}
+153
View File
@@ -0,0 +1,153 @@
import { getSQLiteClient } from '@/services/database/sqlite-client';
export interface VoiceUsageLogEntry {
sessionId?: string | null;
helperName?: string | null;
requestId: string;
messageId?: string | null;
voice: string;
model: string;
charCount: number;
costUsd: number;
durationMs?: number | null;
textPreview?: string | null;
}
type ChatRow = {
id: number;
metadata: string | null;
};
function parseMetadata(raw: unknown): Record<string, any> {
if (!raw) return {};
if (typeof raw === 'string') {
try {
return JSON.parse(raw);
} catch (error) {
console.warn('[VoiceUsage] Failed to parse chat metadata JSON', error);
return {};
}
}
if (typeof raw === 'object') {
return (raw as Record<string, any>) || {};
}
return {};
}
function applyVoiceUsageMetadata(
metadata: Record<string, any>,
entry: VoiceUsageLogEntry,
loggedAt: string
): Record<string, any> {
const usageList = Array.isArray(metadata.voice_usage) ? metadata.voice_usage : [];
const usageEntry = {
request_id: entry.requestId,
message_id: entry.messageId ?? null,
chars: entry.charCount,
cost_usd: entry.costUsd,
voice: entry.voice,
model: entry.model,
duration_ms: entry.durationMs ?? null,
logged_at: loggedAt,
};
usageList.push(usageEntry);
const MAX_USAGE_HISTORY = 20;
metadata.voice_usage = usageList.slice(-MAX_USAGE_HISTORY);
const currentCharsTotal = Number(metadata.voice_tts_chars_total) || 0;
const currentCostTotal = Number(metadata.voice_tts_cost_usd_total) || Number(metadata.voice_tts_cost_total_usd) || 0;
const currentRequestCount = Number(metadata.voice_tts_request_count) || 0;
metadata.voice_tts_chars_total = currentCharsTotal + entry.charCount;
metadata.voice_tts_cost_usd_total = parseFloat((currentCostTotal + entry.costUsd).toFixed(6));
metadata.voice_tts_request_count = currentRequestCount + 1;
metadata.voice_tts_chars = entry.charCount;
metadata.voice_tts_cost_usd = entry.costUsd;
metadata.voice_request_id = entry.requestId;
metadata.voice_tts_voice = entry.voice;
metadata.voice_tts_model = entry.model;
metadata.voice_tts_duration_ms = entry.durationMs ?? null;
metadata.voice_tts_last_logged_at = loggedAt;
metadata.voice_message_id = entry.messageId ?? null;
return metadata;
}
export function recordVoiceUsage(entry: VoiceUsageLogEntry): void {
try {
const sqlite = getSQLiteClient();
const loggedAt = new Date().toISOString();
let chatId: number | null = null;
if (entry.sessionId) {
try {
const row = sqlite
.prepare(
`
SELECT id, metadata
FROM chats
WHERE json_extract(metadata, '$.session_id') = ?
ORDER BY id DESC
LIMIT 1
`
)
.get(entry.sessionId) as ChatRow | undefined;
if (row) {
chatId = row.id;
const parsedMetadata = applyVoiceUsageMetadata(parseMetadata(row.metadata), entry, loggedAt);
sqlite
.prepare(`UPDATE chats SET metadata = ? WHERE id = ?`)
.run(JSON.stringify(parsedMetadata), row.id);
} else {
console.warn(`[VoiceUsage] No chat row found for session ${entry.sessionId}, logging standalone entry.`);
}
} catch (error) {
console.error('[VoiceUsage] Failed to attach usage to chat metadata', error);
}
}
try {
sqlite
.prepare(
`
INSERT INTO voice_usage (
chat_id,
session_id,
helper_name,
request_id,
message_id,
voice,
model,
chars,
cost_usd,
duration_ms,
text_preview,
created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
)
.run(
chatId,
entry.sessionId ?? null,
entry.helperName ?? null,
entry.requestId,
entry.messageId ?? null,
entry.voice,
entry.model,
entry.charCount,
entry.costUsd,
entry.durationMs ?? null,
entry.textPreview ?? null,
loggedAt
);
} catch (error) {
console.error('[VoiceUsage] Failed to insert voice usage row', error);
}
} catch (outerError) {
console.error('[VoiceUsage] Unexpected error while recording usage', outerError);
}
}
+30
View File
@@ -0,0 +1,30 @@
import { INTEGRATE_WORKFLOW_INSTRUCTIONS } from '@/config/workflows/integrate';
import type { WorkflowDefinition } from './types';
export class WorkflowRegistry {
private static readonly WORKFLOWS: Record<string, WorkflowDefinition> = {
'integrate': {
id: 1,
key: 'integrate',
displayName: 'Integrate',
description: 'Deep analysis and connection-building for focused node',
instructions: INTEGRATE_WORKFLOW_INSTRUCTIONS,
enabled: true,
requiresFocusedNode: true,
primaryActor: 'oracle',
expectedOutcome: 'Focused node updated with insights; 3-5 high-value edges created',
}
};
static async getWorkflowByKey(key: string): Promise<WorkflowDefinition | null> {
return this.WORKFLOWS[key] || null;
}
static async getEnabledWorkflows(): Promise<WorkflowDefinition[]> {
return Object.values(this.WORKFLOWS).filter(w => w.enabled);
}
static async getAllWorkflows(): Promise<WorkflowDefinition[]> {
return Object.values(this.WORKFLOWS);
}
}
+11
View File
@@ -0,0 +1,11 @@
export interface WorkflowDefinition {
id: number;
key: string;
displayName: string;
description: string;
instructions: string;
enabled: boolean;
requiresFocusedNode: boolean;
primaryActor: 'oracle' | 'main';
expectedOutcome?: string;
}