feat: align MCP retrieval contract with ra-h

- add external retrieval and confirmation-gated writeback tools across MCP surfaces
- port direct-search-first retrieval support and supporting ranking/query updates
- update MCP docs and skills to treat agent memory files as optional reinforcement

Generated with Claude Code
This commit is contained in:
“BeeRad”
2026-04-13 20:50:13 +10:00
parent 5dcb2b7df6
commit d825e7a783
19 changed files with 1875 additions and 73 deletions
+60 -4
View File
@@ -1,15 +1,67 @@
import type { Node } from '@/types/database';
const SEARCH_STOP_WORDS = new Set([
'a', 'about', 'added', 'already', 'an', 'and', 'are', 'as', 'at', 'be', 'by',
'can', 'created', 'do', 'find', 'for', 'from', 'hello', 'i', 'in', 'into', 'is',
'it', 'just', 'look', 'me', 'my', 'node', 'of', 'on', 'or', 'pull', 'recent',
'recently', 'saved', 'shared', 'show', 'some', 'stuff', 'term', 'that', 'the', 'this',
'to', 'versus', 'were', 'what', 'with', 'wrote', 'you', 'doing', 'going', 'having',
]);
function collapseCompactHyphenatedTerms(value: string): string {
return value.replace(/\b([a-z0-9]{1,3})-([a-z0-9]{1,3})(?:-([a-z0-9]{1,3}))?\b/gi, (_match, a, b, c) => {
return `${a}${b}${c ?? ''}`;
});
}
function normalizeSearchText(value: string): string {
return value
return collapseCompactHyphenatedTerms(value)
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function getQueryTerms(query: string): string[] {
return normalizeSearchText(query).split(' ').filter(term => term.length > 0);
function singularizeTerm(term: string): string {
if (term.endsWith('ies') && term.length > 4) {
return `${term.slice(0, -3)}y`;
}
if (term.endsWith('s') && term.length > 4 && !term.endsWith('ss') && !term.endsWith('us')) {
return term.slice(0, -1);
}
return term;
}
export function getHighSignalSearchTerms(query: string): string[] {
const seen = new Set<string>();
const terms: string[] = [];
for (const rawTerm of normalizeSearchText(query).split(' ')) {
const term = singularizeTerm(rawTerm.trim());
if (!term || term.length < 3) continue;
if (SEARCH_STOP_WORDS.has(term)) continue;
if (seen.has(term)) continue;
seen.add(term);
terms.push(term);
}
return terms;
}
export function countHighSignalQueryTermMatches(
node: Pick<Node, 'title' | 'description' | 'source'>,
query: string,
): number {
const terms = getHighSignalSearchTerms(query);
if (terms.length === 0) return 0;
const haystack = [
normalizeSearchText(node.title || ''),
normalizeSearchText(node.description || ''),
normalizeSearchText(node.source || ''),
].join(' ');
return terms.filter(term => haystack.includes(term)).length;
}
function countOccurrences(text: string, term: string): number {
@@ -33,7 +85,7 @@ export function scoreNodeSearchMatch(node: Pick<Node, 'title' | 'description' |
const normalizedTitle = normalizeSearchText(node.title || '');
const normalizedDescription = normalizeSearchText(node.description || '');
const normalizedSource = normalizeSearchText(node.source || '');
const terms = getQueryTerms(query);
const terms = getHighSignalSearchTerms(query);
let score = 0;
@@ -47,6 +99,10 @@ export function scoreNodeSearchMatch(node: Pick<Node, 'title' | 'description' |
if (orderedTermMatches(normalizedDescription, terms)) score += 120;
if (normalizedSource.includes(normalizedQuery)) score += 90;
const matchedTermCount = countHighSignalQueryTermMatches(node, query);
score += matchedTermCount * 120;
if (terms.length > 0 && matchedTermCount === terms.length) score += 300;
for (const term of terms) {
score += countOccurrences(normalizedTitle, term) * 40;
score += countOccurrences(normalizedDescription, term) * 8;