semantic MCP discovery (Ollama nomic-embed-text), fix stray-dot corruption in localModelTools, route to qwen3.8fast
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user