From 970844c953e8cbeb133fe31e09ea779971c0b33e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 21 Apr 2026 04:07:44 +0000 Subject: [PATCH] Improve operational search ranking Co-authored-by: Kent C. Dodds --- .../worker/src/mcp/tools/search.node.test.ts | 191 ++++++++++++++ packages/worker/src/mcp/tools/search.ts | 232 ++++++++++++++++-- 2 files changed, 407 insertions(+), 16 deletions(-) diff --git a/packages/worker/src/mcp/tools/search.node.test.ts b/packages/worker/src/mcp/tools/search.node.test.ts index 58284ce9e1..8083df71aa 100644 --- a/packages/worker/src/mcp/tools/search.node.test.ts +++ b/packages/worker/src/mcp/tools/search.node.test.ts @@ -5,6 +5,7 @@ import { loadDownHomeConnectorStatus, loadOptionalSearchRows, resolveSearchMemoryContext, + searchPackages, searchUnified, type OptionalSearchRowsResult, type PackageSearchRow, @@ -141,6 +142,196 @@ test('searchUnified ranks mixed search rows through one shared pipeline', () => ) }) +test('searchUnified prioritizes spotify package and connector for operational queries', () => { + const registry = buildCapabilityRegistry([]) + const optionalRows = { + packageRows: [ + { + record: { + id: 'pkg-spotify', + userId: 'user-1', + name: '@kody/spotify-controller', + kodyId: 'spotify-controller', + description: 'Control Spotify playback from saved workflows.', + tags: ['spotify', 'audio'], + searchText: 'spotify playback', + sourceId: 'source-spotify', + hasApp: false, + createdAt: '2026-04-20T00:00:00.000Z', + updatedAt: '2026-04-20T00:00:00.000Z', + }, + projection: { + name: '@kody/spotify-controller', + kodyId: 'spotify-controller', + description: 'Control Spotify playback from saved workflows.', + tags: ['spotify', 'audio'], + searchText: 'spotify playback', + hasApp: false, + exports: ['./playback'], + jobs: [], + }, + }, + { + record: { + id: 'pkg-generic-player', + userId: 'user-1', + name: '@kody/laptop-music-player', + kodyId: 'laptop-music-player', + description: 'Play music on your laptop with saved automations.', + tags: ['music', 'laptop'], + searchText: 'play music on laptop', + sourceId: 'source-generic-player', + hasApp: false, + createdAt: '2026-04-20T00:00:00.000Z', + updatedAt: '2026-04-20T00:00:00.000Z', + }, + projection: { + name: '@kody/laptop-music-player', + kodyId: 'laptop-music-player', + description: 'Play music on your laptop with saved automations.', + tags: ['music', 'laptop'], + searchText: 'play music on laptop', + hasApp: false, + exports: ['./start'], + jobs: [], + }, + }, + ], + userSecretRows: [], + userValueRows: [ + { + name: buildConnectorValueName('spotify'), + scope: 'user', + value: JSON.stringify({ + tokenUrl: 'https://accounts.spotify.com/api/token', + apiBaseUrl: 'https://api.spotify.com/v1', + flow: 'confidential', + clientIdValueName: 'spotify-client-id', + clientSecretSecretName: 'spotify-client-secret', + accessTokenSecretName: 'spotify-access-token', + refreshTokenSecretName: 'spotify-refresh-token', + requiredHosts: ['api.spotify.com'], + }), + description: 'Spotify connector for playback control.', + appId: null, + createdAt: '2026-04-20T00:00:00.000Z', + updatedAt: '2026-04-20T00:00:00.000Z', + ttlMs: null, + }, + { + name: buildConnectorValueName('laptop-audio'), + scope: 'user', + value: JSON.stringify({ + tokenUrl: 'https://player.example/token', + apiBaseUrl: 'https://player.example/api', + flow: 'confidential', + clientIdValueName: 'player-client-id', + clientSecretSecretName: 'player-client-secret', + accessTokenSecretName: 'player-access-token', + refreshTokenSecretName: 'player-refresh-token', + requiredHosts: ['player.example'], + }), + description: 'Play music on your laptop.', + appId: null, + createdAt: '2026-04-20T00:00:00.000Z', + updatedAt: '2026-04-20T00:00:00.000Z', + ttlMs: null, + }, + ], + warnings: [], + } satisfies OptionalSearchRowsResult + + const result = searchUnified({ + env: {} as Env, + query: 'spotify play music on my laptop', + limit: 4, + registry, + optionalRows, + }) + + expect(result.matches.slice(0, 2)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'package', + kodyId: 'spotify-controller', + }), + expect.objectContaining({ + type: 'connector', + connectorName: 'spotify', + }), + ]), + ) +}) + +test('searchPackages prefers the named integration over generic action matches', async () => { + const rows: Array = [ + { + record: { + id: 'pkg-gmail', + userId: 'user-1', + name: '@kody/gmail-actions', + kodyId: 'gmail-actions', + description: 'Run Gmail actions from saved workflows.', + tags: ['gmail', 'email'], + searchText: 'gmail inbox actions', + sourceId: 'source-gmail', + hasApp: false, + createdAt: '2026-04-20T00:00:00.000Z', + updatedAt: '2026-04-20T00:00:00.000Z', + }, + projection: { + name: '@kody/gmail-actions', + kodyId: 'gmail-actions', + description: 'Run Gmail actions from saved workflows.', + tags: ['gmail', 'email'], + searchText: 'gmail inbox actions', + hasApp: false, + exports: ['./mail'], + jobs: [], + }, + }, + { + record: { + id: 'pkg-generic-email', + userId: 'user-1', + name: '@kody/laptop-email-sender', + kodyId: 'laptop-email-sender', + description: 'Send email from your laptop with saved workflows.', + tags: ['email', 'laptop'], + searchText: 'send email from laptop', + sourceId: 'source-generic-email', + hasApp: false, + createdAt: '2026-04-20T00:00:00.000Z', + updatedAt: '2026-04-20T00:00:00.000Z', + }, + projection: { + name: '@kody/laptop-email-sender', + kodyId: 'laptop-email-sender', + description: 'Send email from your laptop with saved workflows.', + tags: ['email', 'laptop'], + searchText: 'send email from laptop', + hasApp: false, + exports: ['./send'], + jobs: [], + }, + }, + ] + + const result = await searchPackages({ + env: {} as Env, + baseUrl: 'https://example.com', + query: 'gmail send email from my laptop', + limit: 2, + rows, + }) + + expect(result.offline).toBe(true) + expect(result.matches[0]).toMatchObject({ + type: 'package', + kodyId: 'gmail-actions', + }) +}) + test('search memory context falls back to the query when omitted', () => { expect( resolveSearchMemoryContext({ diff --git a/packages/worker/src/mcp/tools/search.ts b/packages/worker/src/mcp/tools/search.ts index 926b8c0516..c4f5744851 100644 --- a/packages/worker/src/mcp/tools/search.ts +++ b/packages/worker/src/mcp/tools/search.ts @@ -63,6 +63,93 @@ const maxTokens = 6_000 const maxChars = maxTokens * charsPerToken const defaultSearchLimit = 15 const defaultMaxResponseSize = 4_000 +const searchTokenPattern = /[a-z0-9]+/g +const operationalQueryStopwords = new Set([ + 'a', + 'an', + 'and', + 'app', + 'apps', + 'automation', + 'automations', + 'by', + 'channel', + 'channels', + 'computer', + 'connector', + 'connectors', + 'create', + 'created', + 'creating', + 'data', + 'delete', + 'desktop', + 'doc', + 'docs', + 'document', + 'documents', + 'email', + 'emails', + 'event', + 'events', + 'file', + 'files', + 'find', + 'for', + 'from', + 'get', + 'in', + 'inbox', + 'into', + 'job', + 'jobs', + 'laptop', + 'list', + 'mail', + 'messages', + 'message', + 'me', + 'music', + 'my', + 'note', + 'notes', + 'oauth', + 'of', + 'on', + 'open', + 'or', + 'package', + 'packages', + 'phone', + 'play', + 'playlist', + 'playlists', + 'post', + 'run', + 'saved', + 'search', + 'send', + 'song', + 'songs', + 'start', + 'stop', + 'task', + 'tasks', + 'team', + 'the', + 'time', + 'to', + 'tool', + 'tools', + 'update', + 'use', + 'user', + 'via', + 'with', + 'workflow', + 'workflows', + 'your', +]) export type PackageSearchRow = { record: Awaited>[number] @@ -76,6 +163,93 @@ export type OptionalSearchRowsResult = { warnings: Array } +function normalizeSearchText(text: string): string { + return text + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() +} + +function extractSearchTokens(text: string): Array { + return normalizeSearchText(text).match(searchTokenPattern) ?? [] +} + +function extractMeaningfulQueryTokens(query: string): Array { + const meaningfulTokens: Array = [] + const seenTokens = new Set() + for (const token of extractSearchTokens(query)) { + if (token.length < 3) continue + if (operationalQueryStopwords.has(token)) continue + if (seenTokens.has(token)) continue + seenTokens.add(token) + meaningfulTokens.push(token) + } + return meaningfulTokens +} + +function scoreExactTokenCoverage( + queryTokens: ReadonlyArray, + fields: ReadonlyArray, +): number { + if (queryTokens.length === 0) return 0 + const fieldTokens = new Set() + for (const field of fields) { + if (!field) continue + for (const token of extractSearchTokens(field)) { + fieldTokens.add(token) + } + } + if (fieldTokens.size === 0) return 0 + let matches = 0 + for (const token of queryTokens) { + if (fieldTokens.has(token)) matches += 1 + } + return matches / queryTokens.length +} + +function hasPrimaryPhraseMatch( + query: string, + fields: ReadonlyArray, +): boolean { + const normalizedQuery = extractSearchTokens(query).join(' ') + if (!normalizedQuery) return false + for (const field of fields) { + if (!field) continue + const normalizedField = extractSearchTokens(field).join(' ') + if (!normalizedField.includes(' ')) continue + if (normalizedQuery.includes(normalizedField)) return true + } + return false +} + +function scoreOperationalIdentityBoost(input: { + query: string + primaryFields: ReadonlyArray + secondaryFields?: ReadonlyArray + tertiaryFields?: ReadonlyArray +}): number { + const meaningfulQueryTokens = extractMeaningfulQueryTokens(input.query) + if (meaningfulQueryTokens.length === 0) return 0 + const primaryCoverage = scoreExactTokenCoverage( + meaningfulQueryTokens, + input.primaryFields, + ) + const secondaryCoverage = scoreExactTokenCoverage( + meaningfulQueryTokens, + input.secondaryFields ?? [], + ) + const tertiaryCoverage = scoreExactTokenCoverage( + meaningfulQueryTokens, + input.tertiaryFields ?? [], + ) + return ( + primaryCoverage * 0.85 + + secondaryCoverage * 0.35 + + tertiaryCoverage * 0.2 + + (hasPrimaryPhraseMatch(input.query, input.primaryFields) ? 0.2 : 0) + ) +} + export function searchUnified(input: { env: Env query: string @@ -108,17 +282,28 @@ export function searchUnified(input: { const packageMatches = input.optionalRows.packageRows .map((entry) => { const doc = [ - entry.record.name, - entry.record.kodyId, - entry.record.description, - entry.record.tags.join(' '), - entry.record.searchText ?? '', + entry.projection.name, + entry.projection.kodyId, + entry.projection.description, + entry.projection.tags.join(' '), + entry.projection.searchText ?? '', + entry.projection.exports.join(' '), + entry.projection.jobs.map((job) => job.name).join(' '), ].join('\n') const lexical = lexicalScore(query, doc) const vector = cosineSimilarity( queryEmbedding, deterministicEmbedding(doc), ) + const identityBoost = scoreOperationalIdentityBoost({ + query, + primaryFields: [entry.record.kodyId, entry.record.name], + secondaryFields: entry.record.tags, + tertiaryFields: [ + ...entry.projection.exports, + ...entry.projection.jobs.map((job) => job.name), + ], + }) return { type: 'package' as const, packageId: entry.record.id, @@ -128,7 +313,7 @@ export function searchUnified(input: { description: entry.record.description, tags: entry.record.tags, hasApp: entry.record.hasApp, - score: hybridSearchScore(lexical, vector), + score: hybridSearchScore(lexical, vector) + identityBoost, } }) .filter((match) => match.score > 0) @@ -171,15 +356,21 @@ export function searchUnified(input: { flow: config.flow, apiBaseUrl: config.apiBaseUrl ?? null, requiredHosts: config.requiredHosts ?? [], - score: lexicalScore( - query, - [ - connectorName, - row.description, - config.tokenUrl, - config.apiBaseUrl ?? '', - ].join('\n'), - ), + score: + lexicalScore( + query, + [ + connectorName, + row.description, + config.tokenUrl, + config.apiBaseUrl ?? '', + ].join('\n'), + ) + + scoreOperationalIdentityBoost({ + query, + primaryFields: [connectorName], + secondaryFields: config.requiredHosts ?? [], + }), }, ] }) @@ -237,9 +428,18 @@ export async function searchPackages(input: { queryEmbedding, deterministicEmbedding(document), ) + const identityBoost = scoreOperationalIdentityBoost({ + query, + primaryFields: [row.record.kodyId, row.record.name], + secondaryFields: row.projection.tags, + tertiaryFields: [ + ...row.projection.exports, + ...row.projection.jobs.map((job) => job.name), + ], + }) return { row, - score: lexical + vector, + score: lexical + vector + identityBoost, } }) .sort((left, right) => right.score - left.score)