Initial commit: RA-H Open Source Edition
Local-first knowledge management system with BYO API keys. Features: - 3-panel UI (Nodes | Focus | Helpers) - SQLite + sqlite-vec for vector search - Agent system (Easy/Hard mode orchestrators) - Content extraction (YouTube, PDF, web) - Integrate workflow for connection discovery - Dimension system with auto-assignment Tech stack: - Next.js 15 + TypeScript + Tailwind CSS - Anthropic (Claude) + OpenAI (GPT) via Vercel AI SDK Setup: npm install && npm rebuild better-sqlite3 scripts/dev/bootstrap-local.sh npm run dev MIT License
This commit is contained in:
@@ -0,0 +1,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;
|
||||
}
|
||||
Reference in New Issue
Block a user