Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 119 additions & 28 deletions src/mcp/mcp.c
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ enum {
#endif
#include <yyjson/yyjson.h>
#include <stdint.h> // int64_t
#include <limits.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
Expand Down Expand Up @@ -379,8 +380,9 @@ static const tool_def_t TOOLS[] = {
"\"type\":\"array\",\"items\":{\"type\":\"string\"},\"description\":\"MUST be an ARRAY of "
"keyword strings (e.g. [\\\"send\\\",\\\"pubsub\\\",\\\"publish\\\"]) — NOT a single string. "
"Each keyword is scored independently via per-keyword min-cosine; results reflect functions "
"that score well on ALL keywords. Requires moderate/full index mode. Results appear in the "
"'semantic_results' field (separate from 'results').\"},\"limit\":{\"type\":"
"that score well on ALL keywords. Requires moderate/full index mode. Semantic-only calls "
"return the primary semantic table (or JSON 'results'); combined calls add a semantic "
"sidecar table (or JSON 'semantic_results').\"},\"limit\":{\"type\":"
"\"integer\",\"description\":\"Max results per call. Default 50. Response carries "
"'total' (full match count) and 'has_more' (true if truncated) so callers can "
"detect the limit and paginate.\"},\"offset\":{\"type\":\"integer\",\"default\":0,"
Expand Down Expand Up @@ -1972,20 +1974,19 @@ static int extract_semantic_keywords(yyjson_val *sq_val, const char **keywords,
return ki;
}

/* Emit cbm_vector_result_t entries as a "semantic_results" array on the doc. */
static void emit_semantic_results(yyjson_mut_doc *doc, yyjson_mut_val *root,
cbm_vector_result_t *vresults, int vcount) {
yyjson_mut_val *sem_results = yyjson_mut_arr(doc);
for (int v = 0; v < vcount; v++) {
static void emit_vector_results(yyjson_mut_doc *doc, yyjson_mut_val *root, const char *field,
cbm_vector_result_t *vresults, int start, int vcount) {
yyjson_mut_val *results = yyjson_mut_arr(doc);
for (int v = start; v < start + vcount; v++) {
yyjson_mut_val *vitem = yyjson_mut_obj(doc);
yyjson_mut_obj_add_strcpy(doc, vitem, "name", vresults[v].name);
yyjson_mut_obj_add_strcpy(doc, vitem, "qualified_name", vresults[v].qualified_name);
yyjson_mut_obj_add_strcpy(doc, vitem, "label", vresults[v].label);
yyjson_mut_obj_add_strcpy(doc, vitem, "file_path", vresults[v].file_path);
yyjson_mut_obj_add_real(doc, vitem, "score", vresults[v].score);
yyjson_mut_arr_add_val(sem_results, vitem);
yyjson_mut_arr_add_val(results, vitem);
}
yyjson_mut_obj_add_val(doc, root, "semantic_results", sem_results);
yyjson_mut_obj_add_val(doc, root, field, results);
}

/* Run the semantic_query vector search from raw args. Sets *out_vresults /
Expand All @@ -1994,10 +1995,13 @@ static void emit_semantic_results(yyjson_mut_doc *doc, yyjson_mut_val *root,
* caller should surface to the user). */
static bool run_semantic_query_core(const char *args, cbm_store_t *store, const char *project,
int limit, cbm_vector_result_t **out_vresults, int *out_vcount,
bool *out_present) {
int *out_total, bool *out_present) {
enum { MAX_KW_SEARCH = 32 };
*out_vresults = NULL;
*out_vcount = 0;
if (out_total) {
*out_total = 0;
}
if (out_present) {
*out_present = false;
}
Expand All @@ -2016,12 +2020,16 @@ static bool run_semantic_query_core(const char *args, cbm_store_t *store, const
cbm_vector_result_t *vresults = NULL;
int vcount = 0;
int sem_limit = limit > 0 ? limit : CBM_SZ_16;
if (cbm_store_vector_search(store, project, keywords, ki, sem_limit, &vresults, &vcount) ==
CBM_STORE_OK &&
int total = 0;
if (cbm_store_vector_search(store, project, keywords, ki, sem_limit, &vresults, &vcount,
out_total ? &total : NULL) == CBM_STORE_OK &&
vcount > 0) {
*out_vresults = vresults;
*out_vcount = vcount;
}
if (out_total) {
*out_total = total;
}
}
if (args_doc) {
yyjson_doc_free(args_doc);
Expand All @@ -2037,14 +2045,20 @@ static bool run_semantic_query(yyjson_mut_doc *doc, yyjson_mut_val *root, const
cbm_vector_result_t *vresults = NULL;
int vcount = 0;
bool type_error =
run_semantic_query_core(args, store, project, limit, &vresults, &vcount, NULL);
run_semantic_query_core(args, store, project, limit, &vresults, &vcount, NULL, NULL);
if (vcount > 0) {
emit_semantic_results(doc, root, vresults, vcount);
emit_vector_results(doc, root, "semantic_results", vresults, 0, vcount);
cbm_store_free_vector_results(vresults, vcount);
}
return type_error;
}

static int semantic_fetch_limit_for_page(int limit, int offset) {
int page_limit = limit > 0 ? limit : CBM_SZ_16;
long long page_end = (long long)(offset > 0 ? offset : 0) + (long long)page_limit;
return page_end > INT_MAX ? INT_MAX : (int)page_end;
}

/* ── TOON output for search_graph ───────────────────────────────────
* Default response encoding: header+rows tables (compact_out.h). The
* verbose per-node JSON objects remain available via format:"json" and
Expand Down Expand Up @@ -2154,11 +2168,11 @@ static void emit_search_results_toon(cbm_sb_t *sb, const cbm_search_output_t *ou
}

/* Emit semantic vector-search results as a TOON table. */
static void emit_semantic_results_toon(cbm_sb_t *sb, const cbm_vector_result_t *vresults,
static void emit_semantic_results_toon(cbm_sb_t *sb, const cbm_vector_result_t *vresults, int start,
int vcount) {
static const char *const cols[] = {"qn", "label", "file", "score"};
cbm_toon_table_header(sb, "semantic", vcount, cols, 4);
for (int v = 0; v < vcount; v++) {
for (int v = start; v < start + vcount; v++) {
cbm_toon_row_begin(sb);
cbm_toon_cell_str(sb, vresults[v].qualified_name, true);
cbm_toon_cell_str(sb, vresults[v].label, false);
Expand Down Expand Up @@ -2248,23 +2262,87 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) {
.max_degree = max_degree,
};

bool has_filters = label || name_pattern || qn_pattern || file_pattern || relationship ||
exclude_entry_points || include_connected || min_degree != CBM_NOT_FOUND ||
max_degree != CBM_NOT_FOUND;

if (legacy_json && !has_filters) {
int page_limit = limit > 0 ? limit : CBM_SZ_16;
int semantic_fetch_limit = semantic_fetch_limit_for_page(limit, offset);
cbm_vector_result_t *vresults = NULL;
int vcount = 0;
int vtotal = 0;
bool sq_present = false;
bool sq_type_error = run_semantic_query_core(args, store, project, semantic_fetch_limit,
&vresults, &vcount, &vtotal, &sq_present);
if (sq_type_error) {
free(project);
free(label);
free(name_pattern);
free(qn_pattern);
free(file_pattern);
free(relationship);
return cbm_mcp_text_result(
"semantic_query must be an array of keyword strings, e.g. "
"[\"send\",\"pubsub\",\"publish\"] — not a single string. Split your query "
"into individual keywords; each is scored independently via per-keyword "
"min-cosine.",
true);
}
if (sq_present) {
int start = offset > 0 ? offset : 0;
if (start > vcount) {
start = vcount;
}
int page_count = vcount - start;
if (page_count > page_limit) {
page_count = page_limit;
}
yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL);
yyjson_mut_val *root = yyjson_mut_obj(doc);
yyjson_mut_doc_set_root(doc, root);
yyjson_mut_obj_add_int(doc, root, "total", vtotal);
yyjson_mut_obj_add_str(doc, root, "search_mode", "semantic");
emit_vector_results(doc, root, "results", vresults, start, page_count);
yyjson_mut_obj_add_bool(doc, root, "has_more", vtotal > offset + page_count);
char *json = yy_doc_to_str(doc);
yyjson_mut_doc_free(doc);
if (vresults) {
cbm_store_free_vector_results(vresults, vcount);
}
free(project);
free(label);
free(name_pattern);
free(qn_pattern);
free(file_pattern);
free(relationship);
char *result = cbm_mcp_text_result(json, false);
free(json);
return result;
}
}

if (!legacy_json) {
const char *fields[SG_MAX_EXTRA_FIELDS];
yyjson_doc *fields_owner = NULL;
int nfields = sg_parse_fields(args, fields, SG_MAX_EXTRA_FIELDS, &fields_owner);

int semantic_fetch_limit = limit > 0 ? limit : CBM_SZ_16;
if (!has_filters) {
semantic_fetch_limit = semantic_fetch_limit_for_page(limit, offset);
}

cbm_vector_result_t *vresults = NULL;
int vcount = 0;
int vtotal = 0;
bool sq_present = false;
bool sq_type_error =
run_semantic_query_core(args, store, project, limit, &vresults, &vcount, &sq_present);
run_semantic_query_core(args, store, project, semantic_fetch_limit, &vresults, &vcount,
has_filters ? NULL : &vtotal, &sq_present);
if (!sq_type_error) {
/* Semantic-only calls get semantic results only: the legacy
* behavior also ran the UNFILTERED regex search and prepended
* up to `limit` unrelated enriched nodes to the response. */
bool has_filters = label || name_pattern || qn_pattern || file_pattern ||
relationship || exclude_entry_points ||
min_degree != CBM_NOT_FOUND || max_degree != CBM_NOT_FOUND;
bool semantic_only = sq_present && !has_filters;

cbm_sb_t sb;
Expand All @@ -2290,14 +2368,27 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) {
}
}
}
if (vcount > 0) {
emit_semantic_results_toon(&sb, vresults, vcount);
} else if (semantic_only) {
static const char *const sem_cols[] = {"qn", "label", "file", "score"};
cbm_toon_table_header(&sb, "semantic", 0, sem_cols, 4);
cbm_toon_scalar_str(&sb, "hint",
"No semantic matches. semantic_query needs a moderate/full "
"index; try broader or fewer keywords.");
if (semantic_only) {
int start = offset > 0 ? offset : 0;
if (start > vcount) {
start = vcount;
}
int page_count = vcount - start;
int page_limit = limit > 0 ? limit : CBM_SZ_16;
if (page_count > page_limit) {
page_count = page_limit;
}
cbm_toon_scalar_str(&sb, "search_mode", "semantic");
cbm_toon_scalar_int(&sb, "total", vtotal);
emit_semantic_results_toon(&sb, vresults, start, page_count);
cbm_toon_scalar_bool(&sb, "has_more", vtotal > offset + page_count);
if (vcount == 0) {
cbm_toon_scalar_str(&sb, "hint",
"No semantic matches. semantic_query needs a moderate/full "
"index; try broader or fewer keywords.");
}
} else if (vcount > 0) {
emit_semantic_results_toon(&sb, vresults, 0, vcount);
}
if (vcount > 0) {
cbm_store_free_vector_results(vresults, vcount);
Expand Down
72 changes: 58 additions & 14 deletions src/store/store.c
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
// for ISO timestamp

#include <stdint.h>
#include <limits.h>
#include "foundation/constants.h"

#include <math.h>
Expand Down Expand Up @@ -6898,11 +6899,48 @@ static cbm_vector_result_t *vs_append_result(cbm_vector_result_t *results, int *
return results;
}

static int vs_count_candidates(cbm_store_t *s, const char *project) {
const char *sql = "SELECT count(*)"
" FROM node_vectors v"
" INNER JOIN nodes n ON n.id = v.node_id"
" WHERE v.project = ?1"
" AND n.label IN ('Function','Method','Class')";
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db, sql, SQLITE_AUTO_LEN, &stmt, NULL) != SQLITE_OK) {
return 0;
}
sqlite3_bind_text(stmt, SKIP_ONE, project, SQLITE_AUTO_LEN, SQLITE_STATIC);
int total = 0;
if (sqlite3_step(stmt) == SQLITE_ROW) {
total = sqlite3_column_int(stmt, 0);
}
sqlite3_finalize(stmt);
return total;
}

static int vs_result_score_desc(const void *lhs, const void *rhs) {
const cbm_vector_result_t *a = lhs;
const cbm_vector_result_t *b = rhs;
if (a->score < b->score) {
return 1;
}
if (a->score > b->score) {
return -1;
}
if (a->node_id < b->node_id) {
return -1;
}
return a->node_id > b->node_id;
}

int cbm_store_vector_search(cbm_store_t *s, const char *project, const char **keywords,
int keyword_count, int limit, cbm_vector_result_t **out,
int *out_count) {
int keyword_count, int limit, cbm_vector_result_t **out, int *out_count,
int *total_count) {
*out = NULL;
*out_count = 0;
if (total_count) {
*total_count = 0;
}
if (!s || !project || !keywords || keyword_count <= 0) {
return CBM_STORE_ERR;
}
Expand All @@ -6912,6 +6950,11 @@ int cbm_store_vector_search(cbm_store_t *s, const char *project, const char **ke
if (actual_kw == 0) {
return CBM_STORE_OK;
}
int candidate_total = 0;
if (total_count) {
candidate_total = vs_count_candidates(s, project);
*total_count = candidate_total;
}

/* Scan all node vectors, compute per-keyword cosine, take min.
* We use the FIRST keyword as the SQL sort (for top-K pre-filter),
Expand All @@ -6932,8 +6975,14 @@ int cbm_store_vector_search(cbm_store_t *s, const char *project, const char **ke
return CBM_STORE_ERR;
}

/* Use first keyword for SQL pre-filter, fetch more candidates for re-ranking */
int fetch_limit = (limit > 0 ? limit : CBM_SZ_16) * ST_COL_5;
/* Semantic pagination needs a stable ordering across pages, so callers
* requesting a total scan the full candidate set before min-score reranking.
* Sidecar searches keep the bounded first-keyword prefilter. */
int requested_limit = limit > 0 ? limit : CBM_SZ_16;
int fetch_limit = candidate_total;
if (!total_count) {
fetch_limit = requested_limit > INT_MAX / ST_COL_5 ? INT_MAX : requested_limit * ST_COL_5;
}
sqlite3_bind_blob(stmt, SKIP_ONE, kw_vecs[0], VS_VEC_DIM, SQLITE_STATIC);
sqlite3_bind_text(stmt, ST_COL_2, project, SQLITE_AUTO_LEN, SQLITE_STATIC);
sqlite3_bind_int(stmt, ST_COL_3, fetch_limit);
Expand Down Expand Up @@ -6972,19 +7021,14 @@ int cbm_store_vector_search(cbm_store_t *s, const char *project, const char **ke
}
sqlite3_finalize(stmt);

/* Re-sort by min-score (SQL sorted by first keyword only) */
for (int i = 0; i < count - SKIP_ONE; i++) {
for (int j = i + SKIP_ONE; j < count; j++) {
if (results[j].score > results[i].score) {
cbm_vector_result_t tmp = results[i];
results[i] = results[j];
results[j] = tmp;
}
}
/* Re-sort by min-score (SQL sorted by first keyword only). Node ID is a
* deterministic tie-breaker so repeated pages keep the same ordering. */
if (count > 1) {
qsort(results, (size_t)count, sizeof(*results), vs_result_score_desc);
}

/* Trim to requested limit */
int final_limit = limit > 0 ? limit : CBM_SZ_16;
int final_limit = requested_limit;
if (count > final_limit) {
for (int i = final_limit; i < count; i++) {
free(results[i].name);
Expand Down
4 changes: 2 additions & 2 deletions src/store/store.h
Original file line number Diff line number Diff line change
Expand Up @@ -719,8 +719,8 @@ typedef struct {
* the cbm_cosine_i8 SQL function joined with the nodes table.
* Returns results sorted by score DESC. Caller must free with cbm_store_free_vector_results. */
int cbm_store_vector_search(cbm_store_t *s, const char *project, const char **keywords,
int keyword_count, int limit, cbm_vector_result_t **out,
int *out_count);
int keyword_count, int limit, cbm_vector_result_t **out, int *out_count,
int *total_count);

/* Free vector search results. */
void cbm_store_free_vector_results(cbm_vector_result_t *results, int count);
Expand Down
Loading
Loading