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).
|
// Shared local-LLM client — Ollama on shadow-death (.128).
|
||||||
// Single source of truth for host/model across the whole app.
|
// 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_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 {
|
export interface LocalModelOptions {
|
||||||
formatJson?: boolean;
|
formatJson?: boolean;
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ export async function executeLocalModelTool(toolName: string, args: any): Promis
|
|||||||
case 'ollama_delegate_prompt': {
|
case 'ollama_delegate_prompt': {
|
||||||
const {
|
const {
|
||||||
prompt,
|
prompt,
|
||||||
model = 'llama3.2:1b',
|
model = 'qwen3.8fast:latest',
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
endpoint = 'http://10.30.20.128:11434',
|
endpoint = 'http://10.30.20.128:11434',
|
||||||
temperature = 0.7,
|
temperature = 0.7,
|
||||||
@@ -227,7 +227,7 @@ export async function executeLocalModelTool(toolName: string, args: any): Promis
|
|||||||
const {
|
const {
|
||||||
imageUrlOrBase64,
|
imageUrlOrBase64,
|
||||||
prompt = 'Describe this image in detail, extract any visible text, and detect key objects.',
|
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',
|
endpoint = 'http://10.30.20.128:11434',
|
||||||
} = args;
|
} = 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[] = [
|
export const MCP_DISCOVERY_TOOLS: ToolDefinition[] = [
|
||||||
{
|
{
|
||||||
name: 'mcp_discovery_search',
|
name: 'mcp_discovery_search',
|
||||||
@@ -110,6 +198,26 @@ export const MCP_DISCOVERY_TOOLS: ToolDefinition[] = [
|
|||||||
required: ['query'],
|
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',
|
name: 'mcp_discovery_readme',
|
||||||
description:
|
description:
|
||||||
@@ -164,6 +272,27 @@ export async function executeMcpDiscoveryTool(
|
|||||||
break;
|
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': {
|
case 'mcp_discovery_readme': {
|
||||||
loadCatalog();
|
loadCatalog();
|
||||||
loadReadmes();
|
loadReadmes();
|
||||||
|
|||||||
Reference in New Issue
Block a user