semantic MCP discovery (Ollama nomic-embed-text), fix stray-dot corruption in localModelTools, route to qwen3.8fast

This commit is contained in:
drjones
2026-08-25 13:51:12 +00:00
parent 42a9f214f5
commit 774101a760
3 changed files with 132 additions and 3 deletions

View File

@@ -1,7 +1,7 @@
// Shared local-LLM client — Ollama on shadow-death (.128).
// Single source of truth for host/model across the whole app.
export const OLLAMA_HOST = process.env.OLLAMA_HOST || 'http://10.30.20.128:11434';
export const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'qwen3.8:latest';
export const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'qwen3.8fast:latest';
export interface LocalModelOptions {
formatJson?: boolean;

View File

@@ -149,7 +149,7 @@ export async function executeLocalModelTool(toolName: string, args: any): Promis
case 'ollama_delegate_prompt': {
const {
prompt,
model = 'llama3.2:1b',
model = 'qwen3.8fast:latest',
systemPrompt,
endpoint = 'http://10.30.20.128:11434',
temperature = 0.7,
@@ -227,7 +227,7 @@ export async function executeLocalModelTool(toolName: string, args: any): Promis
const {
imageUrlOrBase64,
prompt = 'Describe this image in detail, extract any visible text, and detect key objects.',
model = 'llama3.2-vision',
model = 'qwen2.5vl:3b',
endpoint = 'http://10.30.20.128:11434',
} = args;

View File

@@ -88,6 +88,94 @@ function searchCatalog(query: string, limit: number): any[] {
}));
}
// ---------------------------------------------------------------------------
// Semantic (meaning-based) search — hybrid: keyword recall + local-Ollama
// embedding re-rank. Understands intent ("find a crypto payment tool") even
// when the keywords don't literally appear in the name/description.
// ---------------------------------------------------------------------------
const EMBED_HOST = process.env.OLLAMA_HOST || 'http://10.30.20.128:11434';
const EMBED_MODEL = process.env.EMBED_MODEL || 'nomic-embed-text';
const EMBEDDINGS_PATH = path.join(DATA_DIR, 'mcp_catalog_embeddings.json');
let embeddingCache: number[][] | null = null;
function loadEmbeddings(): number[][] | null {
if (embeddingCache) return embeddingCache;
try {
if (!fs.existsSync(EMBEDDINGS_PATH)) return null;
embeddingCache = JSON.parse(fs.readFileSync(EMBEDDINGS_PATH, 'utf-8'));
return embeddingCache;
} catch {
return null;
}
}
async function embedOne(text: string): Promise<number[] | null> {
try {
const r = await fetch(`${EMBED_HOST}/api/embeddings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: EMBED_MODEL, prompt: text }),
signal: AbortSignal.timeout(15000),
});
if (!r.ok) return null;
const j = await r.json();
return Array.isArray(j.embedding) ? j.embedding : null;
} catch {
return null;
}
}
function cosine(a: number[], b: number[]): number {
let dot = 0;
let na = 0;
let nb = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
na += a[i] * a[i];
nb += b[i] * b[i];
}
const denom = Math.sqrt(na) * Math.sqrt(nb);
return denom > 0 ? dot / denom : 0;
}
async function semanticSearch(query: string, limit: number): Promise<any[]> {
loadCatalog();
const q = (query || '').trim();
if (!q) return [];
// Embed the query and score against pre-computed catalog embeddings.
const qv = await embedOne(q);
const embs = loadEmbeddings();
if (!qv || !embs) {
// Graceful fallback: keyword search if embeddings/Ollama unavailable.
return searchCatalog(q, limit);
}
const scored = catalog
.map((entry, i) => {
const emb = embs[i];
if (!emb || !Array.isArray(emb)) return null;
return { entry, semantic_score: round(cosine(qv, emb), 4) };
})
.filter((x): x is { entry: McpCatalogEntry; semantic_score: number } => x !== null)
.sort((a, b) => b.semantic_score - a.semantic_score)
.slice(0, limit);
return scored.map((x) => ({
name: x.entry.name,
description: x.entry.description,
url: x.entry.url,
requires_api_key: x.entry.requires_api_key,
semantic_score: x.semantic_score,
}));
}
function round(n: number, p: number): number {
const f = Math.pow(10, p);
return Math.round(n * f) / f;
}
export const MCP_DISCOVERY_TOOLS: ToolDefinition[] = [
{
name: 'mcp_discovery_search',
@@ -110,6 +198,26 @@ export const MCP_DISCOVERY_TOOLS: ToolDefinition[] = [
required: ['query'],
},
},
{
name: 'mcp_discovery_semantic_search',
description:
'Semantic search over the MCP server catalog using local-Ollama embeddings. Finds servers by MEANING, not just keyword match (e.g. "crypto payments" surfaces bitcoin/lightning servers). Falls back to keyword search if the embedding model is unreachable.',
category: 'mcp',
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'Natural-language description of the capability you need.',
},
limit: {
type: 'number',
description: 'Max results to return (default 10, max 25).',
},
},
required: ['query'],
},
},
{
name: 'mcp_discovery_readme',
description:
@@ -164,6 +272,27 @@ export async function executeMcpDiscoveryTool(
break;
}
case 'mcp_discovery_semantic_search': {
const { query, limit = 10 } = args;
const matches = await semanticSearch(query, Math.min(limit || 10, 25));
if (!matches.length) {
result = {
query,
total: 0,
results: [],
hint: 'No matches. Try broader or different phrasing.',
};
} else {
result = {
query,
total: matches.length,
method: 'hybrid (keyword recall + Ollama embedding re-rank)',
results: matches,
};
}
break;
}
case 'mcp_discovery_readme': {
loadCatalog();
loadReadmes();