feat: port MCP retrieval and write hardening

- align os MCP/runtime/docs with ra-h contract
- add safe direct node lookup and context-optional flows
- gate edge and context writes behind confirmation
This commit is contained in:
“BeeRad”
2026-04-14 20:57:07 +10:00
parent d825e7a783
commit 929423cb21
35 changed files with 1172 additions and 534 deletions
+82 -170
View File
@@ -1,24 +1,24 @@
/**
* Auto-Edge Creation Service
* Potential edge suggestion helper.
*
* After Quick Capture creates a node, this service:
* 1. Extracts candidate entity strings from the node's description
* 2. Looks up existing entity nodes by exact title match
* 3. Creates edges with explanations for matches
*
* This is a "fast path" for obvious connections only - conservative by design.
* This module no longer writes edges automatically. It only identifies
* obvious connection candidates so an agent or UI can propose them first.
*/
import { nodeService, edgeService } from '@/services/database';
import { nodeService } from '@/services/database';
import { Node } from '@/types/database';
/**
* Clean up a candidate entity string by removing common prefixes/suffixes.
*/
export interface PotentialEdgeSuggestion {
from_node_id: number;
to_node_id: number;
to_node_title: string;
explanation: string;
candidate_text: string;
}
function cleanEntityCandidate(candidate: string): string {
let cleaned = candidate.trim();
// Remove common author/attribution prefixes
const prefixPatterns = [
/^by\s+/i,
/^author:\s*/i,
@@ -37,65 +37,8 @@ function cleanEntityCandidate(candidate: string): string {
return cleaned.trim();
}
/**
* Extract candidate entity strings from text using conservative heuristics.
* Returns proper names, quoted titles, and recognized patterns.
*/
function extractCandidateEntities(text: string): string[] {
if (!text || typeof text !== 'string') return [];
const candidates: Set<string> = new Set();
// Pattern 1: "By [Name]" pattern - common in article descriptions
// Matches: "By Simon Willison", "by Sam Altman"
const byPattern = /\b[Bb]y\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,2})\b/g;
let match;
while ((match = byPattern.exec(text)) !== null) {
const name = match[1].trim();
if (name.length >= 4 && !isGenericPhrase(name)) {
candidates.add(name);
}
}
// Pattern 2: Proper name sequences (2-4 capitalized words)
// Matches: "Sam Altman", "Dario Amodei", "Peter Thiel"
const properNamePattern = /\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,3})\b/g;
while ((match = properNamePattern.exec(text)) !== null) {
const name = match[1].trim();
// Clean the candidate (remove "By ", etc.)
const cleaned = cleanEntityCandidate(name);
if (cleaned.length >= 4 && !isGenericPhrase(cleaned)) {
candidates.add(cleaned);
}
}
// Pattern 3: Quoted titles (single or double quotes)
// Matches: "Zero to One", 'The Lean Startup'
const quotedPattern = /["']([^"']{3,60})["']/g;
while ((match = quotedPattern.exec(text)) !== null) {
const title = match[1].trim();
if (title.length >= 3 && !isGenericPhrase(title)) {
candidates.add(title);
}
}
// Pattern 4: Known organization patterns
// Matches: OpenAI, DeepMind, Y Combinator, Fly.io
const orgPattern = /\b(OpenAI|DeepMind|Anthropic|Google|Microsoft|Meta|Apple|Amazon|Y Combinator|YC|Stripe|Coinbase|Fly\.io|Vercel|Cloudflare)\b/gi;
while ((match = orgPattern.exec(text)) !== null) {
candidates.add(match[1]);
}
return Array.from(candidates);
}
/**
* Check if a phrase is too generic to be a useful entity reference.
*/
function isGenericPhrase(phrase: string): boolean {
const normalized = phrase.toLowerCase();
// Common stopwords and generic terms
const genericTerms = [
'the author', 'the article', 'the book', 'the podcast',
'this article', 'this book', 'this podcast', 'this paper',
@@ -105,30 +48,58 @@ function isGenericPhrase(phrase: string): boolean {
'united states', 'new york', 'san francisco', 'silicon valley'
];
return genericTerms.some(term => normalized === term || normalized.startsWith(term + ' '));
return genericTerms.some(term => normalized === term || normalized.startsWith(`${term} `));
}
function extractCandidateEntities(text: string): string[] {
if (!text || typeof text !== 'string') return [];
const candidates: Set<string> = new Set();
const byPattern = /\b[Bb]y\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,2})\b/g;
let match;
while ((match = byPattern.exec(text)) !== null) {
const name = match[1].trim();
if (name.length >= 4 && !isGenericPhrase(name)) {
candidates.add(name);
}
}
const properNamePattern = /\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,3})\b/g;
while ((match = properNamePattern.exec(text)) !== null) {
const cleaned = cleanEntityCandidate(match[1].trim());
if (cleaned.length >= 4 && !isGenericPhrase(cleaned)) {
candidates.add(cleaned);
}
}
const quotedPattern = /["']([^"']{3,60})["']/g;
while ((match = quotedPattern.exec(text)) !== null) {
const title = match[1].trim();
if (title.length >= 3 && !isGenericPhrase(title)) {
candidates.add(title);
}
}
const orgPattern = /\b(OpenAI|DeepMind|Anthropic|Google|Microsoft|Meta|Apple|Amazon|Y Combinator|YC|Stripe|Coinbase|Fly\.io|Vercel|Cloudflare)\b/gi;
while ((match = orgPattern.exec(text)) !== null) {
candidates.add(match[1]);
}
return Array.from(candidates);
}
/**
* Look up existing nodes that match candidate entity strings.
* Uses exact title matching (case-insensitive).
*/
async function findMatchingEntityNodes(candidates: string[]): Promise<Map<string, Node>> {
const matches = new Map<string, Node>();
if (candidates.length === 0) return matches;
// Get all nodes (we'll filter in memory for exact title matches)
// In a larger system, we'd use a more efficient query
const allNodes = await nodeService.getNodes({ limit: 10000 });
for (const candidate of candidates) {
const normalizedCandidate = candidate.toLowerCase().trim();
// Find exact title match (case-insensitive)
const matchingNode = allNodes.find(node => {
const matchingNode = allNodes.find((node) => {
const normalizedTitle = (node.title || '').toLowerCase().trim();
if (normalizedTitle !== normalizedCandidate) return false;
return node.title.length < 80;
return normalizedTitle === normalizedCandidate && node.title.length < 80;
});
if (matchingNode) {
@@ -139,95 +110,36 @@ async function findMatchingEntityNodes(candidates: string[]): Promise<Map<string
return matches;
}
/**
* Create edges from a new node to matched entity nodes.
* Each edge includes an explanation for auditability.
*/
async function createAutoEdges(
newNodeId: number,
matches: Map<string, Node>
): Promise<number> {
let edgesCreated = 0;
export async function suggestPotentialEdgesForNode(nodeId: number): Promise<PotentialEdgeSuggestion[]> {
const node = await nodeService.getNodeById(nodeId);
if (!node) {
console.warn(`[autoEdge] Node ${nodeId} not found, skipping suggestion lookup`);
return [];
}
const description = node.description || '';
if (!description || description.length < 10) {
return [];
}
const candidates = extractCandidateEntities(description);
if (candidates.length === 0) {
return [];
}
const matches = await findMatchingEntityNodes(candidates);
const suggestions: PotentialEdgeSuggestion[] = [];
for (const [candidateText, entityNode] of matches) {
// Skip self-references
if (entityNode.id === newNodeId) continue;
// Check if edge already exists
const exists = await edgeService.edgeExists(newNodeId, entityNode.id);
if (exists) continue;
try {
await edgeService.createEdge({
from_node_id: newNodeId,
to_node_id: entityNode.id,
explanation: `Explicitly mentioned in description: "${candidateText}"`,
created_via: 'quick_capture_auto',
source: 'ai_similarity',
skip_inference: false, // Let Idea Genealogy classify the relationship
});
edgesCreated++;
console.log(`[autoEdge] Created edge: ${newNodeId}${entityNode.id} (${entityNode.title})`);
} catch (error) {
console.warn(`[autoEdge] Failed to create edge to ${entityNode.id}:`, error);
}
}
return edgesCreated;
}
/**
* Main entry point: Run auto-edge creation for a newly created node.
* This is designed to be called fire-and-forget (non-blocking).
*/
export async function runAutoEdgeCreation(nodeId: number): Promise<void> {
try {
// Fetch the newly created node
const node = await nodeService.getNodeById(nodeId);
if (!node) {
console.warn(`[autoEdge] Node ${nodeId} not found, skipping auto-edge creation`);
return;
}
// Use description as the source of truth for entity extraction
const description = node.description || '';
if (!description || description.length < 10) {
console.log(`[autoEdge] Node ${nodeId} has no/short description, skipping`);
return;
}
// Extract candidate entities from description
const candidates = extractCandidateEntities(description);
if (candidates.length === 0) {
console.log(`[autoEdge] No entity candidates found in node ${nodeId} description`);
return;
}
console.log(`[autoEdge] Found ${candidates.length} candidates for node ${nodeId}:`, candidates);
// Find matching existing nodes
const matches = await findMatchingEntityNodes(candidates);
if (matches.size === 0) {
console.log(`[autoEdge] No matching entity nodes found for node ${nodeId}`);
return;
}
// Create edges
const edgesCreated = await createAutoEdges(nodeId, matches);
console.log(`[autoEdge] Created ${edgesCreated} auto-edges for node ${nodeId}`);
} catch (error) {
console.error(`[autoEdge] Error in auto-edge creation for node ${nodeId}:`, error);
}
}
/**
* Schedule auto-edge creation to run asynchronously (fire-and-forget).
* Use this from the nodes API to avoid blocking the response.
*/
export function scheduleAutoEdgeCreation(nodeId: number): void {
setImmediate(() => {
runAutoEdgeCreation(nodeId).catch(error => {
console.error(`[autoEdge] Scheduled auto-edge creation failed for node ${nodeId}:`, error);
if (entityNode.id === nodeId) continue;
suggestions.push({
from_node_id: nodeId,
to_node_id: entityNode.id,
to_node_title: entityNode.title,
explanation: `Explicitly mentioned in description: "${candidateText}"`,
candidate_text: candidateText,
});
});
}
return suggestions;
}
+21 -3
View File
@@ -234,6 +234,7 @@ async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: strin
});
} catch (error) {
const message = error instanceof Error ? error.message : `Failed to execute ${toolName}`;
const capturedAt = new Date().toISOString();
const title = deriveFallbackLinkTitle(url);
const description =
`Link record for this source. RA-H could not correctly process the URL during ingestion because ${message}. Stored so the source is not lost and can be revisited later.`;
@@ -257,10 +258,15 @@ async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: strin
captured_method: 'quick_add_link_fallback',
captured_by: 'human',
source_metadata: {
capture_origin: 'quick_add',
capture_path: 'quick_add_link_fallback',
explicit_capture: true,
source_url: url,
attempted_pipeline: type,
extraction_failed: true,
extraction_error: message,
refined_at: new Date().toISOString(),
captured_at: capturedAt,
refined_at: capturedAt,
},
},
context_id: contextId,
@@ -296,6 +302,7 @@ async function handleNoteQuickAdd(rawInput: string, task: string, userDescriptio
throw new Error('Input is required to create a note');
}
const capturedAt = new Date().toISOString();
const title = deriveNoteTitle(content);
const nodePayload: Record<string, unknown> = {
title,
@@ -307,7 +314,12 @@ async function handleNoteQuickAdd(rawInput: string, task: string, userDescriptio
captured_method: 'quick_add_note',
captured_by: 'human',
source_metadata: {
refined_at: new Date().toISOString(),
capture_origin: 'quick_add',
capture_path: 'quick_add_note',
explicit_capture: true,
input_type: 'note',
captured_at: capturedAt,
refined_at: capturedAt,
},
},
};
@@ -351,6 +363,7 @@ async function handleChatTranscriptQuickAdd(rawInput: string, task: string, cont
throw new Error('Input is required to import a chat transcript');
}
const capturedAt = new Date().toISOString();
const summaryResult = await summarizeTranscript(transcript);
const baseSummary = summaryResult.summary?.trim() || 'Captured chat transcript. Review the raw transcript for full detail.';
@@ -395,7 +408,12 @@ async function handleChatTranscriptQuickAdd(rawInput: string, task: string, cont
transcript_length_chars: transcript.length,
transcript_length_words: wordCount,
transcript_truncated_for_summary: summaryResult.truncated ?? false,
summary_generated_at: new Date().toISOString(),
capture_origin: 'quick_add',
capture_path: 'quick_add_chat',
explicit_capture: true,
input_type: 'chat',
captured_at: capturedAt,
summary_generated_at: capturedAt,
},
};
+140
View File
@@ -0,0 +1,140 @@
import { contextService } from '@/services/database/contextService';
import { nodeService } from '@/services/database/nodes';
import { countHighSignalQueryTermMatches, getHighSignalSearchTerms, scoreNodeSearchMatch } from '@/services/database/searchRanking';
import type { Node } from '@/types/database';
export interface DirectNodeLookupInput {
search?: string;
limit?: number;
context_name?: string;
contextId?: number;
createdAfter?: string;
createdBefore?: string;
eventAfter?: string;
eventBefore?: string;
}
export interface DirectNodeLookupResult {
nodes: Node[];
count: number;
filtersApplied: {
search?: string;
limit: number;
context_name?: string;
createdAfter?: string;
createdBefore?: string;
eventAfter?: string;
eventBefore?: string;
};
}
function normalizeContextName(value: string | undefined): string | undefined {
if (typeof value !== 'string') return undefined;
const normalized = value.trim().replace(/\s+/g, ' ');
return normalized || undefined;
}
async function resolveSearchContext(input: DirectNodeLookupInput): Promise<{ contextId?: number; context_name?: string }> {
const normalizedName = normalizeContextName(input.context_name);
if (normalizedName) {
const context = await contextService.getContextByName(normalizedName);
if (!context) {
console.warn(`directNodeLookup received unknown context_name "${normalizedName}"; ignoring context filter.`);
return {};
}
return {
contextId: context.id,
context_name: context.name,
};
}
if (typeof input.contextId === 'number') {
const context = await contextService.getContextById(input.contextId);
if (!context) {
console.warn(`directNodeLookup received invalid legacy contextId ${input.contextId}; ignoring context filter.`);
return {};
}
return {
contextId: context.id,
context_name: context.name,
};
}
return {};
}
function hasStrongAnchorMatch(nodes: Node[], searchTerm: string): boolean {
if (!searchTerm || nodes.length === 0) return false;
const highSignalTerms = getHighSignalSearchTerms(searchTerm);
const requiredMatches = Math.min(2, highSignalTerms.length || 1);
return nodes.some(node => countHighSignalQueryTermMatches(node, searchTerm) >= requiredMatches);
}
export async function directNodeLookup(input: DirectNodeLookupInput): Promise<DirectNodeLookupResult> {
const limit = Math.min(Math.max(input.limit ?? 10, 1), 50);
const searchTerm = input.search?.trim();
if (searchTerm && /^\d+$/.test(searchTerm)) {
const nodeId = Number(searchTerm);
const node = await nodeService.getNodeById(nodeId);
return {
nodes: node ? [node] : [],
count: node ? 1 : 0,
filtersApplied: {
search: searchTerm,
limit,
},
};
}
const resolvedContext = await resolveSearchContext(input);
const effectiveFilters = {
search: searchTerm,
limit,
contextId: resolvedContext.contextId,
searchMode: 'standard' as const,
createdAfter: input.createdAfter,
createdBefore: input.createdBefore,
eventAfter: input.eventAfter,
eventBefore: input.eventBefore,
};
let safeNodes = await nodeService.getNodes(effectiveFilters);
const hadExtraFilters = Boolean(
effectiveFilters.contextId !== undefined ||
effectiveFilters.createdAfter ||
effectiveFilters.createdBefore ||
effectiveFilters.eventAfter ||
effectiveFilters.eventBefore
);
if (searchTerm && hadExtraFilters && (safeNodes.length === 0 || !hasStrongAnchorMatch(safeNodes, searchTerm))) {
console.warn(`directNodeLookup falling back to plain literal search for "${searchTerm}" after filtered lookup missed a strong anchor match.`);
safeNodes = await nodeService.searchNodes(searchTerm, limit);
}
if (searchTerm) {
safeNodes = safeNodes
.map(node => ({ node, score: scoreNodeSearchMatch(node, searchTerm) }))
.sort((a, b) => b.score - a.score || b.node.updated_at.localeCompare(a.node.updated_at))
.slice(0, limit)
.map(entry => entry.node);
} else {
safeNodes = safeNodes.slice(0, limit);
}
return {
nodes: safeNodes,
count: safeNodes.length,
filtersApplied: {
search: searchTerm,
limit,
context_name: resolvedContext.context_name,
createdAfter: input.createdAfter,
createdBefore: input.createdBefore,
eventAfter: input.eventAfter,
eventBefore: input.eventBefore,
},
};
}