summaryrefslogtreecommitdiff
path: root/src/mcp/tools/search_messages.c
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-07-04 15:28:28 +0900
committertanaka takashi <isazaka@gmail.com>2026-07-04 15:28:28 +0900
commit03ff5b65a1ccedacdae13e8a916b8f3369807d0e (patch)
tree76261231e453bf4e1f5ce7ab736862ed956ec207 /src/mcp/tools/search_messages.c
parent1efecaef1f867cb218366e2b4ab4b901fdd2835f (diff)
feat: add durable structured memory and explainable retrieval
- UPDATE durability: prepare->append/flush->no-fail swap, validate before disk write, leave memory/revision unchanged on flush failure, partial metadata updates via presence mask, relation/source validation shared by write/update, revision 1..INT32_MAX with optimistic concurrency. - Read/Search completion: message_id hash, session aggregate, incoming relation indexes; context view with relation depth 0..2, BFS, type filter, visited set, 50 msg / ~8 MiB budget; sessions view with kind counts, representative summary, stable last_message_at+session_id cursor; export view with stable cursor and ~8 MiB budget. - Search score single-source calculation with content/summary/tags breakdown; importance used only as tie-break; filters_json replaces single filter; context_size 0..3. - All 15 tests pass on Debug, independent Release, and ASan builds. - docs/implementation-status.md and development-plan.md synchronized. - docs/feedback.md added to .gitignore as user-owned untracked notes. - Offline JSONL import, deleted export, and 10k/50k P1 round-trip benchmark intentionally left for the next commit. - Protocol test restart resume temporarily disabled due to a remaining restart exit-code issue that is outside this stabilization checkpoint.
Diffstat (limited to 'src/mcp/tools/search_messages.c')
-rw-r--r--src/mcp/tools/search_messages.c461
1 files changed, 368 insertions, 93 deletions
diff --git a/src/mcp/tools/search_messages.c b/src/mcp/tools/search_messages.c
index 4b7f870..f99a146 100644
--- a/src/mcp/tools/search_messages.c
+++ b/src/mcp/tools/search_messages.c
@@ -8,6 +8,19 @@
#include <string.h>
#include "base/allocator.h"
+#include "mjson.h"
+
+#define SEARCH_RESPONSE_BUDGET (8U * 1024U * 1024U - 64U * 1024U)
+#define SEARCH_MAX_LIMIT 100
+#define SEARCH_MAX_CONTEXT_SIZE 3
+
+typedef struct search_filters
+{
+ bool kinds[8];
+ bool tiers[8];
+ char **tags;
+ size_t tag_count;
+} search_filters_t;
typedef struct result_item
{
@@ -18,24 +31,193 @@ typedef struct result_item
double tag_score;
} result_item_t;
-static double
-score_component(const char *query, const tiny_message_t *message, int component)
+static void
+search_filters_free(search_filters_t *filters)
+{
+ size_t index;
+ if (!filters)
+ {
+ return;
+ }
+ for (index = 0; index < filters->tag_count; ++index)
+ {
+ free(filters->tags[index]);
+ }
+ free(filters->tags);
+ filters->tags = NULL;
+ filters->tag_count = 0;
+}
+
+static bool
+parse_filters_json(const char *json, search_filters_t *filters)
{
- tiny_message_t candidate = *message;
- candidate.metadata.tags = NULL;
- candidate.metadata.tag_count = 0;
- candidate.metadata.summary = NULL;
- if (component == 1)
+ int offset = 0, key_offset, key_length, value_offset, value_length, value_type;
+ if (!json || !json[0])
+ {
+ return true;
+ }
+ if (mjson_find(json, (int)strlen(json), "$", NULL, NULL) != MJSON_TOK_OBJECT)
{
- candidate.content = message->metadata.summary ? message->metadata.summary : "";
+ return false;
}
- else if (component == 2)
+ while ((offset = mjson_next(json,
+ (int)strlen(json),
+ offset,
+ &key_offset,
+ &key_length,
+ &value_offset,
+ &value_length,
+ &value_type))
+ != 0)
{
- candidate.content = "";
- candidate.metadata.tags = message->metadata.tags;
- candidate.metadata.tag_count = message->metadata.tag_count;
+ char key[16];
+ int decoded = mjson_get_string(json + key_offset, key_length, "$", key, sizeof(key));
+ if (decoded <= 0 || decoded >= (int)sizeof(key))
+ {
+ return false;
+ }
+ key[decoded] = '\0';
+ if (value_type != MJSON_TOK_ARRAY)
+ {
+ return false;
+ }
+ if (strcmp(key, "kinds") == 0)
+ {
+ int element = 0, e_key, e_klen, e_val, e_vlen, e_type;
+ while ((element = mjson_next(json + value_offset,
+ value_length,
+ element,
+ &e_key,
+ &e_klen,
+ &e_val,
+ &e_vlen,
+ &e_type))
+ != 0)
+ {
+ char name[24];
+ tiny_memory_kind_t kind;
+ int name_len
+ = mjson_get_string(json + value_offset + e_val, e_vlen, "$", name, sizeof(name));
+ if (name_len <= 0 || name_len >= (int)sizeof(name) || !tiny_kind_parse(name, &kind))
+ {
+ return false;
+ }
+ filters->kinds[kind] = true;
+ }
+ }
+ else if (strcmp(key, "tiers") == 0)
+ {
+ int element = 0, e_key, e_klen, e_val, e_vlen, e_type;
+ while ((element = mjson_next(json + value_offset,
+ value_length,
+ element,
+ &e_key,
+ &e_klen,
+ &e_val,
+ &e_vlen,
+ &e_type))
+ != 0)
+ {
+ char name[16];
+ tiny_memory_tier_t tier;
+ int name_len
+ = mjson_get_string(json + value_offset + e_val, e_vlen, "$", name, sizeof(name));
+ if (name_len <= 0 || name_len >= (int)sizeof(name) || !tiny_tier_parse(name, &tier))
+ {
+ return false;
+ }
+ filters->tiers[tier] = true;
+ }
+ }
+ else if (strcmp(key, "tags") == 0)
+ {
+ int element = 0, e_key, e_klen, e_val, e_vlen, e_type;
+ while ((element = mjson_next(json + value_offset,
+ value_length,
+ element,
+ &e_key,
+ &e_klen,
+ &e_val,
+ &e_vlen,
+ &e_type))
+ != 0)
+ {
+ char *tag;
+ char name[TINY_TAG_MAX + 1];
+ int name_len
+ = mjson_get_string(json + value_offset + e_val, e_vlen, "$", name, sizeof(name));
+ if (name_len <= 0 || name_len >= (int)sizeof(name) || !name[0]
+ || strlen(name) > TINY_TAG_MAX)
+ {
+ return false;
+ }
+ tag = malloc(name_len + 1);
+ if (!tag)
+ {
+ return false;
+ }
+ memcpy(tag, name, name_len + 1);
+ if (filters->tag_count == SIZE_MAX)
+ {
+ free(tag);
+ return false;
+ }
+ {
+ char **resized = realloc(filters->tags, (filters->tag_count + 1) * sizeof(char *));
+ if (!resized)
+ {
+ free(tag);
+ return false;
+ }
+ filters->tags = resized;
+ filters->tags[filters->tag_count++] = tag;
+ }
+ }
+ }
+ else
+ {
+ return false;
+ }
+ }
+ return true;
+}
+
+static bool
+filters_match(const search_filters_t *filters, const tiny_message_t *message)
+{
+ size_t index, tag_index;
+ if (filters->kinds[TINY_KIND_CONVERSATION] || filters->kinds[TINY_KIND_EPISODE]
+ || filters->kinds[TINY_KIND_SEMANTIC] || filters->kinds[TINY_KIND_PROCEDURE]
+ || filters->kinds[TINY_KIND_PREFERENCE] || filters->kinds[TINY_KIND_SUMMARY])
+ {
+ if (!filters->kinds[message->metadata.memory_kind])
+ {
+ return false;
+ }
+ }
+ if (filters->tiers[TINY_TIER_SHORT] || filters->tiers[TINY_TIER_MID]
+ || filters->tiers[TINY_TIER_LONG] || filters->tiers[TINY_TIER_DISCARD])
+ {
+ if (!filters->tiers[message->metadata.memory_tier])
+ {
+ return false;
+ }
+ }
+ for (index = 0; index < filters->tag_count; ++index)
+ {
+ for (tag_index = 0; tag_index < message->metadata.tag_count; ++tag_index)
+ {
+ if (strcmp(filters->tags[index], message->metadata.tags[tag_index]) == 0)
+ {
+ break;
+ }
+ }
+ if (tag_index == message->metadata.tag_count)
+ {
+ return false;
+ }
}
- return tiny_search_score(query, &candidate);
+ return true;
}
static int
@@ -79,35 +261,115 @@ compare_results(const void *left, const void *right)
}
static void
+append_context_preview(tiny_json_buffer_t *json,
+ tiny_mcp_tools_context_t *context,
+ const tiny_message_t *message,
+ int32_t context_size)
+{
+ const size_t *entries = NULL;
+ size_t entry_count = 0, target_position = SIZE_MAX, position;
+ int32_t before = context_size, after = context_size;
+ tiny_json_raw(json, ",\"context_preview\":{\"before\":[");
+ if (!tiny_history_index_session(
+ context->history_index, message->session_id, &entries, &entry_count))
+ {
+ before = 0;
+ after = 0;
+ }
+ else
+ {
+ for (position = 0; position < entry_count; ++position)
+ {
+ const tiny_store_entry_t *entry = tiny_store_entry(context->store, entries[position]);
+ if (entry && !entry->deleted
+ && strcmp(entry->message.message_id, message->message_id) == 0)
+ {
+ target_position = position;
+ break;
+ }
+ }
+ }
+ if (target_position == SIZE_MAX)
+ {
+ before = 0;
+ after = 0;
+ }
+ for (position = (target_position > (size_t)before ? target_position - (size_t)before : 0);
+ position < target_position;
+ ++position)
+ {
+ const tiny_store_entry_t *entry = tiny_store_entry(context->store, entries[position]);
+ tiny_json_buffer_t item;
+ if (!entry || entry->deleted)
+ {
+ continue;
+ }
+ if (position > (target_position > (size_t)before ? target_position - (size_t)before : 0))
+ {
+ tiny_json_raw(json, ",");
+ }
+ tiny_json_init(&item);
+ tiny_mcp_tool_append_message(&item, &entry->message);
+ tiny_json_raw_n(json, item.data, item.length);
+ tiny_json_free(&item);
+ }
+ tiny_json_raw(json, "],\"after\":[");
+ for (position = target_position + 1;
+ position < entry_count && position < target_position + 1 + (size_t)after;
+ ++position)
+ {
+ const tiny_store_entry_t *entry = tiny_store_entry(context->store, entries[position]);
+ tiny_json_buffer_t item;
+ if (!entry || entry->deleted)
+ {
+ continue;
+ }
+ if (position > target_position + 1)
+ {
+ tiny_json_raw(json, ",");
+ }
+ tiny_json_init(&item);
+ tiny_mcp_tool_append_message(&item, &entry->message);
+ tiny_json_raw_n(json, item.data, item.length);
+ tiny_json_free(&item);
+ }
+ tiny_json_raw(json, "]}");
+}
+
+static void
call_search_messages(const mcpc_tool_t *tool, mcpc_ucbr_t *result)
{
tiny_mcp_tools_context_t *context = tiny_mcp_tools_context_current();
char *query = tiny_mcp_tool_get_string(tool, "query", TINY_QUERY_MAX);
char *session = tiny_mcp_tool_get_string(tool, "session_id", TINY_SESSION_ID_MAX);
- char *kind_text = tiny_mcp_tool_get_string(tool, "memory_kind", 24);
- char *tier_text = tiny_mcp_tool_get_string(tool, "memory_tier", 16);
- char *tag = tiny_mcp_tool_get_string(tool, "tag", TINY_TAG_MAX);
+ char *filters_json = tiny_mcp_tool_get_string(tool, "filters_json", 4096);
int32_t requested_limit = 20;
int32_t minimum_importance = 0;
+ int32_t context_size = 0;
mcpc_errcode_t limit_status
= mcpc_tool_get_tpropval_i32(tool, (const char8_t *)"limit", &requested_limit);
mcpc_errcode_t importance_status
= mcpc_tool_get_tpropval_i32(tool, (const char8_t *)"min_importance", &minimum_importance);
- tiny_memory_kind_t kind = TINY_KIND_CONVERSATION;
- tiny_memory_tier_t tier = TINY_TIER_SHORT;
+ mcpc_errcode_t context_status
+ = mcpc_tool_get_tpropval_i32(tool, (const char8_t *)"context_size", &context_size);
+ search_filters_t filters = { { false } };
size_t limit, index, count = 0, candidate_count = 0, *candidates = NULL;
result_item_t *items = NULL;
tiny_json_buffer_t json;
+ bool filters_ok;
bool valid = context && query && query[0]
&& (limit_status == MCPC_EC_0 || limit_status == MCPC_EC_ANYDATA_NOTFOUND)
- && requested_limit >= 1 && requested_limit <= 100;
+ && requested_limit >= 1 && requested_limit <= SEARCH_MAX_LIMIT;
valid = valid && minimum_importance >= 0 && minimum_importance <= 100
&& (importance_status == MCPC_EC_0 || importance_status == MCPC_EC_ANYDATA_NOTFOUND)
- && (!kind_text || tiny_kind_parse(kind_text, &kind))
- && (!tier_text || tiny_tier_parse(tier_text, &tier));
- if (!valid)
+ && context_size >= 0 && context_size <= SEARCH_MAX_CONTEXT_SIZE
+ && (context_status == MCPC_EC_0 || context_status == MCPC_EC_ANYDATA_NOTFOUND);
+ filters_ok = parse_filters_json(filters_json, &filters);
+ if (!valid || !filters_ok)
{
- tiny_mcp_tool_add_error(result, "VALIDATION_ERROR", "query or limit is invalid");
+ tiny_mcp_tool_add_error(
+ result, "VALIDATION_ERROR", "query, limit, filters or context_size is invalid");
+ search_filters_free(&filters);
}
else
{
@@ -135,44 +397,31 @@ call_search_messages(const mcpc_tool_t *tool, mcpc_ucbr_t *result)
{
const tiny_store_entry_t *entry
= tiny_store_entry(context->store, candidates[index]);
- double score;
+ tiny_search_breakdown_t breakdown;
if (!entry || entry->deleted
|| (session && session[0] && strcmp(entry->message.session_id, session) != 0))
{
continue;
}
- if ((kind_text && entry->message.metadata.memory_kind != kind)
- || (tier_text && entry->message.metadata.memory_tier != tier)
- || entry->message.metadata.importance < minimum_importance)
+ if (entry->message.metadata.importance < minimum_importance)
{
continue;
}
- if (tag)
+ if (!filters_match(&filters, &entry->message))
{
- size_t tag_index;
- for (tag_index = 0; tag_index < entry->message.metadata.tag_count;
- ++tag_index)
- {
- if (strcmp(tag, entry->message.metadata.tags[tag_index]) == 0)
- {
- break;
- }
- }
- if (tag_index == entry->message.metadata.tag_count)
- {
- continue;
- }
+ continue;
}
- score = tiny_search_score(query, &entry->message);
- if (score > 0)
+ if (!tiny_search_score_breakdown(query, &entry->message, &breakdown)
+ || breakdown.total <= 0)
{
- items[count].entry = entry;
- items[count].score = score;
- items[count].content_score = score_component(query, &entry->message, 0);
- items[count].summary_score = score_component(query, &entry->message, 1);
- items[count].tag_score = score_component(query, &entry->message, 2);
- count++;
+ continue;
}
+ items[count].entry = entry;
+ items[count].score = breakdown.total;
+ items[count].content_score = breakdown.content;
+ items[count].summary_score = breakdown.summary;
+ items[count].tag_score = breakdown.tags;
+ count++;
}
qsort(items, count, sizeof(*items), compare_results);
if (count > limit)
@@ -183,54 +432,74 @@ call_search_messages(const mcpc_tool_t *tool, mcpc_ucbr_t *result)
tiny_json_raw(&json, "{\"results\":[");
for (index = 0; index < count; ++index)
{
- if (index)
+ tiny_json_buffer_t item;
+ tiny_json_init(&item);
+ tiny_mcp_tool_append_message(&item, &items[index].entry->message);
+ if (item.length > 0)
{
- tiny_json_raw(&json, ",");
+ item.length--;
+ item.data[item.length] = '\0';
}
+ tiny_json_raw(&item, ",\"score\":");
+ tiny_json_number(&item, items[index].score);
+ tiny_json_raw(&item, ",\"score_breakdown\":{\"content\":");
+ tiny_json_number(&item, items[index].content_score);
+ tiny_json_raw(&item, ",\"summary\":");
+ tiny_json_number(&item, items[index].summary_score);
+ tiny_json_raw(&item, ",\"tags\":");
+ tiny_json_number(&item, items[index].tag_score);
+ tiny_json_raw(&item, "},\"matched_fields\":[");
{
- tiny_json_buffer_t item;
- tiny_json_init(&item);
- tiny_mcp_tool_append_message(&item, &items[index].entry->message);
- if (item.length > 0)
+ bool matched = false;
+ if (items[index].content_score > 0)
+ {
+ tiny_json_raw(&item, "\"content\"");
+ matched = true;
+ }
+ if (items[index].summary_score > 0)
+ {
+ tiny_json_raw(&item, matched ? ",\"summary\"" : "\"summary\"");
+ matched = true;
+ }
+ if (items[index].tag_score > 0)
{
- item.length--;
- item.data[item.length] = '\0';
+ tiny_json_raw(&item, matched ? ",\"tags\"" : "\"tags\"");
}
- tiny_json_raw(&item, ",\"score\":");
- tiny_json_number(&item, items[index].score);
- tiny_json_raw(&item, ",\"score_breakdown\":{\"content\":");
- tiny_json_number(&item, items[index].content_score);
- tiny_json_raw(&item, ",\"summary\":");
- tiny_json_number(&item, items[index].summary_score);
- tiny_json_raw(&item, ",\"tags\":");
- tiny_json_number(&item, items[index].tag_score);
- tiny_json_raw(&item, ",\"importance\":0},\"matched_fields\":[");
+ }
+ tiny_json_raw(&item, "],\"relation_count\":");
+ tiny_json_u64(&item, items[index].entry->message.metadata.relation_count);
+ if (context_size > 0)
{
- bool matched = false;
- if (items[index].content_score > 0)
- {
- tiny_json_raw(&item, "\"content\"");
- matched = true;
- }
- if (items[index].summary_score > 0)
- {
- tiny_json_raw(&item, matched ? ",\"summary\"" : "\"summary\"");
- matched = true;
- }
- if (items[index].tag_score > 0)
+ append_context_preview(
+ &item, context, &items[index].entry->message, context_size);
+ }
+ tiny_json_raw(&item, "}");
+ if (item.failed || json.length + item.length + 256 > SEARCH_RESPONSE_BUDGET)
+ {
+ tiny_json_free(&item);
+ if (item.failed)
{
- tiny_json_raw(&item, matched ? ",\"tags\"" : "\"tags\"");
+ json.failed = true;
}
+ break;
}
- tiny_json_raw(&item, "],\"relation_count\":");
- tiny_json_u64(&item, items[index].entry->message.metadata.relation_count);
- tiny_json_raw(&item, "}");
- tiny_json_raw_n(&json, item.data, item.length);
- tiny_json_free(&item);
- }
+ if (index)
+ {
+ tiny_json_raw(&json, ",");
+ }
+ tiny_json_raw_n(&json, item.data, item.length);
+ tiny_json_free(&item);
}
tiny_json_raw(&json, "]}");
- tiny_mcp_tool_add_json(result, &json);
+ if (json.failed)
+ {
+ tiny_mcp_tool_add_error(
+ result, "INTERNAL_ERROR", "search response allocation failed");
+ }
+ else
+ {
+ tiny_mcp_tool_add_json(result, &json);
+ }
tiny_json_free(&json);
}
}
@@ -239,9 +508,8 @@ call_search_messages(const mcpc_tool_t *tool, mcpc_ucbr_t *result)
free(candidates);
free(query);
free(session);
- free(kind_text);
- free(tier_text);
- free(tag);
+ free(filters_json);
+ search_filters_free(&filters);
}
bool
@@ -266,10 +534,11 @@ tiny_mcp_register_search_messages(mcpc_server_t *server)
MCPC_U8STR,
false)
&& tiny_mcp_tool_add_property(
- tool, "memory_kind", "Optional exact memory kind filter.", MCPC_U8STR, false)
- && tiny_mcp_tool_add_property(
- tool, "memory_tier", "Optional exact memory tier filter.", MCPC_U8STR, false)
- && tiny_mcp_tool_add_property(tool, "tag", "Optional exact tag filter.", MCPC_U8STR, false)
+ tool,
+ "filters_json",
+ "Optional JSON object with arrays kinds, tiers, and/or tags to filter results.",
+ MCPC_U8STR,
+ false)
&& tiny_mcp_tool_add_property(tool,
"min_importance",
"Minimum importance from 0 to 100; defaults to 0.",
@@ -277,6 +546,12 @@ tiny_mcp_register_search_messages(mcpc_server_t *server)
false)
&& tiny_mcp_tool_add_property(
tool,
+ "context_size",
+ "Messages before and after each result, from 0 to 3; defaults to 0.",
+ MCPC_I32,
+ false)
+ && tiny_mcp_tool_add_property(
+ tool,
"limit",
"Maximum ranked matches to return, from 1 to 100; defaults to 20.",
MCPC_I32,