summaryrefslogtreecommitdiff
path: root/src/mcp/tools
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
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')
-rw-r--r--src/mcp/tools/common/history.h6
-rw-r--r--src/mcp/tools/read_messages.c232
-rw-r--r--src/mcp/tools/search_messages.c461
3 files changed, 554 insertions, 145 deletions
diff --git a/src/mcp/tools/common/history.h b/src/mcp/tools/common/history.h
index d6f5708..3b23027 100644
--- a/src/mcp/tools/common/history.h
+++ b/src/mcp/tools/common/history.h
@@ -5,6 +5,12 @@
typedef struct tiny_history_index tiny_history_index_t;
+typedef struct tiny_history_index_session_info
+{
+ const char *session_id;
+ int64_t last_message_at;
+} tiny_history_index_session_info_t;
+
tiny_history_index_t *tiny_history_index_build(const tiny_store_t *store);
void tiny_history_index_free(tiny_history_index_t *index);
bool
diff --git a/src/mcp/tools/read_messages.c b/src/mcp/tools/read_messages.c
index aa76ce8..079e6eb 100644
--- a/src/mcp/tools/read_messages.c
+++ b/src/mcp/tools/read_messages.c
@@ -225,8 +225,8 @@ relation_type_mask(const char *json, uint32_t *mask)
int decoded;
tiny_relation_type_t type;
if (value_type != MJSON_TOK_STRING
- || (decoded = mjson_get_string(
- json + value_offset, value_length, "$", value, sizeof(value)))
+ || (decoded
+ = mjson_get_string(json + value_offset, value_length, "$", value, sizeof(value)))
<= 0
|| decoded >= (int)sizeof(value))
{
@@ -247,11 +247,17 @@ compare_context_edges(const void *left, const void *right)
{
const context_edge_t *a = left, *b = right;
if (a->depth != b->depth)
- return a->depth < b->depth ? -1 : 1;
+ {
+ return a->depth < b->depth ? -1 : 1;
+ }
if (a->type != b->type)
- return a->type < b->type ? -1 : 1;
+ {
+ return a->type < b->type ? -1 : 1;
+ }
if (a->timestamp != b->timestamp)
- return a->timestamp < b->timestamp ? -1 : 1;
+ {
+ return a->timestamp < b->timestamp ? -1 : 1;
+ }
return strcmp(a->message_id, b->message_id);
}
@@ -277,8 +283,8 @@ call_context_view(tiny_mcp_tools_context_t *context, const mcpc_tool_t *tool, mc
bool *visited = NULL, truncated = false;
uint32_t type_mask;
tiny_json_buffer_t json;
- if (!message_id || before < 0 || before > 10 || after < 0 || after > 10
- || relation_depth < 0 || relation_depth > 2
+ if (!message_id || before < 0 || before > 10 || after < 0 || after > 10 || relation_depth < 0
+ || relation_depth > 2
|| (before_status != MCPC_EC_0 && before_status != MCPC_EC_ANYDATA_NOTFOUND)
|| (after_status != MCPC_EC_0 && after_status != MCPC_EC_ANYDATA_NOTFOUND)
|| (depth_status != MCPC_EC_0 && depth_status != MCPC_EC_ANYDATA_NOTFOUND)
@@ -370,10 +376,7 @@ call_context_view(tiny_mcp_tools_context_t *context, const mcpc_tool_t *tool, mc
break;
}
if (!tiny_memory_index_incoming(
- context->memory_index,
- current->message.message_id,
- &incoming,
- &incoming_count))
+ context->memory_index, current->message.message_id, &incoming, &incoming_count))
{
continue;
}
@@ -436,8 +439,7 @@ call_context_view(tiny_mcp_tools_context_t *context, const mcpc_tool_t *tool, mc
}
tiny_json_raw(&json, "],\"after\":[");
emitted = 0;
- for (position = target_position + 1;
- position < session_entry_count && emitted < (size_t)after;
+ for (position = target_position + 1; position < session_entry_count && emitted < (size_t)after;
++position)
{
const tiny_store_entry_t *entry = tiny_store_entry(context->store, entries[position]);
@@ -472,7 +474,8 @@ call_context_view(tiny_mcp_tools_context_t *context, const mcpc_tool_t *tool, mc
tiny_json_init(&item);
tiny_json_raw(&item, "{\"type\":");
tiny_json_string(&item, tiny_relation_type_name(edge->type));
- tiny_json_raw(&item, edge->incoming ? ",\"direction\":\"incoming\"" : ",\"direction\":\"outgoing\"");
+ tiny_json_raw(&item,
+ edge->incoming ? ",\"direction\":\"incoming\"" : ",\"direction\":\"outgoing\"");
tiny_json_raw(&item, ",\"depth\":");
tiny_json_u64(&item, edge->depth);
if (related && !related->deleted)
@@ -534,68 +537,173 @@ simple_cursor(const char *cursor, bool *valid)
return *valid ? (size_t)value : 0;
}
+static bool
+session_cursor_encode(int64_t last_message_at, const char *session_id, char output[CURSOR_TEXT_MAX])
+{
+ unsigned char data[CURSOR_BYTES] = { 0 };
+ data[0] = 2;
+ tiny_codec_put_u64(data + 1, (uint64_t)last_message_at);
+ tiny_codec_put_u32(data + 9,
+ tiny_codec_crc32((const unsigned char *)session_id, strlen(session_id)));
+ memcpy(data + 13, session_id, strlen(session_id));
+ tiny_codec_put_u32(data + 43, tiny_codec_crc32(data, 43));
+ return base64url_encode(data, sizeof(data), output, CURSOR_TEXT_MAX) == 63;
+}
+
+static bool
+session_cursor_decode(const char *cursor,
+ int64_t *last_message_at,
+ char session_id[TINY_SESSION_ID_MAX + 1])
+{
+ unsigned char data[CURSOR_BYTES];
+ size_t length;
+ if (!cursor || !cursor[0] || !base64url_decode(cursor, data) || data[0] != 2)
+ {
+ return false;
+ }
+ *last_message_at = (int64_t)tiny_codec_get_u64(data + 1);
+ length = tiny_codec_get_u32(data + 9);
+ if (length == 0 || length > TINY_SESSION_ID_MAX
+ || tiny_codec_crc32(data, 43) != tiny_codec_get_u32(data + 43))
+ {
+ return false;
+ }
+ memcpy(session_id, data + 13, length);
+ session_id[length] = '\0';
+ return tiny_utf8_valid(session_id, length);
+}
+
+static int
+compare_session_by_last_then_id(const void *left, const void *right)
+{
+ const tiny_history_index_session_info_t *a = left;
+ const tiny_history_index_session_info_t *b = right;
+ if (a->last_message_at != b->last_message_at)
+ {
+ return a->last_message_at < b->last_message_at ? -1 : 1;
+ }
+ return strcmp(a->session_id, b->session_id);
+}
+
static void
call_sessions_view(tiny_mcp_tools_context_t *context, const mcpc_tool_t *tool, mcpc_ucbr_t *result)
{
char *cursor = tiny_mcp_tool_get_string(tool, "cursor", CURSOR_TEXT_MAX);
int32_t limit = READ_DEFAULT_LIMIT;
- bool valid;
- size_t start = simple_cursor(cursor, &valid), index, unique = 0, emitted = 0;
tiny_json_buffer_t json;
mcpc_errcode_t limit_status = mcpc_tool_get_tpropval_i32(tool, (const char8_t *)"limit", &limit);
- if (!context || !valid || limit < 1 || limit > READ_MAX_LIMIT
- || (limit_status != MCPC_EC_0 && limit_status != MCPC_EC_ANYDATA_NOTFOUND))
+ int64_t cursor_last = 0;
+ char cursor_session[TINY_SESSION_ID_MAX + 1] = { 0 };
+ bool has_cursor = cursor && cursor[0];
+ size_t emitted = 0, index, position;
+ const tiny_history_index_t *history;
+ tiny_history_index_session_info_t *sessions = NULL;
+ size_t session_count = 0;
+ bool cursor_ok = !has_cursor || session_cursor_decode(cursor, &cursor_last, cursor_session);
+
+ if (!context || !cursor_ok || limit < 1 || limit > READ_MAX_LIMIT
+ || (limit_status != MCPC_EC_0 && limit_status != MCPC_EC_ANYDATA_NOTFOUND)
+ || !tiny_mcp_tools_ensure_history_index(context) || !(history = context->history_index))
{
tiny_mcp_tool_add_error(result, "VALIDATION_ERROR", "invalid sessions view arguments");
free(cursor);
return;
}
- tiny_json_init(&json);
- tiny_json_raw(&json, "{\"sessions\":[");
- for (index = 0; index < tiny_store_entry_count(context->store) && emitted < (size_t)limit;
- ++index)
+
+ session_count = tiny_history_index_session_count(history);
+ if (session_count > 0)
{
- const tiny_store_entry_t *entry = tiny_store_entry(context->store, index);
- size_t prior, scan, count = 0;
- int64_t started = INT64_MAX, ended = 0;
- if (!entry || entry->deleted)
+ sessions = malloc(session_count * sizeof(*sessions));
+ if (!sessions)
{
- continue;
+ tiny_mcp_tool_add_error(result, "INTERNAL_ERROR", "sessions allocation failed");
+ free(cursor);
+ return;
}
- for (prior = 0; prior < index; ++prior)
+ for (index = 0; index < session_count; ++index)
{
- const tiny_store_entry_t *candidate = tiny_store_entry(context->store, prior);
- if (candidate && !candidate->deleted
- && strcmp(candidate->message.session_id, entry->message.session_id) == 0)
+ const char *session_id;
+ const size_t *entries;
+ size_t count;
+ if (!tiny_history_index_session_at(history, index, &session_id, &entries, &count))
{
- break;
+ free(sessions);
+ tiny_mcp_tool_add_error(result, "INTERNAL_ERROR", "session enumeration failed");
+ free(cursor);
+ return;
}
+ sessions[index].session_id = session_id;
+ sessions[index].last_message_at
+ = count > 0
+ ? tiny_store_entry(context->store, entries[count - 1])->message.timestamp_ms
+ : 0;
+ }
+ qsort(sessions, session_count, sizeof(*sessions), compare_session_by_last_then_id);
+ }
+
+ tiny_json_init(&json);
+ tiny_json_raw(&json, "{\"sessions\":[");
+ for (position = 0; position < session_count && emitted < (size_t)limit; ++position)
+ {
+ const char *session_id = sessions[position].session_id;
+ const size_t *entries;
+ size_t count, representative_index = SIZE_MAX;
+ int64_t started = INT64_MAX, ended = 0;
+ int kind_counts[6] = { 0 };
+ const tiny_store_entry_t *representative = NULL;
+ const char *summary = NULL;
+ if (has_cursor
+ && (sessions[position].last_message_at < cursor_last
+ || (sessions[position].last_message_at == cursor_last
+ && strcmp(session_id, cursor_session) <= 0)))
+ {
+ continue;
}
- if (prior != index || unique++ < start)
+ if (!tiny_history_index_session(history, session_id, &entries, &count))
{
continue;
}
- for (scan = index; scan < tiny_store_entry_count(context->store); ++scan)
+ for (index = 0; index < count; ++index)
{
- const tiny_store_entry_t *candidate = tiny_store_entry(context->store, scan);
- if (candidate && !candidate->deleted
- && strcmp(candidate->message.session_id, entry->message.session_id) == 0)
+ const tiny_store_entry_t *entry = tiny_store_entry(context->store, entries[index]);
+ if (!entry || entry->deleted)
{
- count++;
- started = candidate->message.timestamp_ms < started ? candidate->message.timestamp_ms
- : started;
- ended = candidate->message.timestamp_ms > ended ? candidate->message.timestamp_ms
- : ended;
+ continue;
}
+ kind_counts[entry->message.metadata.memory_kind - 1]++;
+ started = entry->message.timestamp_ms < started ? entry->message.timestamp_ms : started;
+ ended = entry->message.timestamp_ms > ended ? entry->message.timestamp_ms : ended;
+ representative_index = entries[index];
+ representative = entry;
+ }
+ if (representative_index == SIZE_MAX)
+ {
+ continue;
}
if (emitted++)
{
tiny_json_raw(&json, ",");
}
+ summary = representative->message.metadata.summary ? representative->message.metadata.summary
+ : representative->message.content;
tiny_json_raw(&json, "{\"session_id\":");
- tiny_json_string(&json, entry->message.session_id);
+ tiny_json_string(&json, session_id);
tiny_json_raw(&json, ",\"message_count\":");
tiny_json_u64(&json, count);
+ tiny_json_raw(&json, ",\"kind_counts\":{\"conversation\":");
+ tiny_json_i64(&json, kind_counts[0]);
+ tiny_json_raw(&json, ",\"episode\":");
+ tiny_json_i64(&json, kind_counts[1]);
+ tiny_json_raw(&json, ",\"semantic\":");
+ tiny_json_i64(&json, kind_counts[2]);
+ tiny_json_raw(&json, ",\"procedure\":");
+ tiny_json_i64(&json, kind_counts[3]);
+ tiny_json_raw(&json, ",\"preference\":");
+ tiny_json_i64(&json, kind_counts[4]);
+ tiny_json_raw(&json, ",\"summary\":");
+ tiny_json_i64(&json, kind_counts[5]);
+ tiny_json_raw(&json, "},\"representative_summary\":");
+ tiny_json_string(&json, summary);
tiny_json_raw(&json, ",\"started_at\":");
tiny_json_i64(&json, started);
tiny_json_raw(&json, ",\"last_message_at\":");
@@ -603,16 +711,20 @@ call_sessions_view(tiny_mcp_tools_context_t *context, const mcpc_tool_t *tool, m
tiny_json_raw(&json, "}");
}
tiny_json_raw(&json, "]");
- if (emitted == (size_t)limit)
+ if (emitted == (size_t)limit && position < session_count)
{
- char next[32];
- snprintf(next, sizeof(next), "%zu", start + emitted);
- tiny_json_raw(&json, ",\"nextCursor\":");
- tiny_json_string(&json, next);
+ char next[CURSOR_TEXT_MAX];
+ if (session_cursor_encode(
+ sessions[position - 1].last_message_at, sessions[position - 1].session_id, next))
+ {
+ tiny_json_raw(&json, ",\"nextCursor\":");
+ tiny_json_string(&json, next);
+ }
}
tiny_json_raw(&json, "}");
tiny_mcp_tool_add_json(result, &json);
tiny_json_free(&json);
+ free(sessions);
free(cursor);
}
@@ -623,8 +735,10 @@ call_export_view(tiny_mcp_tools_context_t *context, const mcpc_tool_t *tool, mcp
char *cursor = tiny_mcp_tool_get_string(tool, "cursor", CURSOR_TEXT_MAX);
int32_t limit = 500;
bool valid;
- size_t index = simple_cursor(cursor, &valid), emitted = 0;
+ size_t index = simple_cursor(cursor, &valid), emitted = 0, start_index;
tiny_json_buffer_t json, lines;
+ size_t outer_size = 64;
+ bool truncated = false;
mcpc_errcode_t limit_status = mcpc_tool_get_tpropval_i32(tool, (const char8_t *)"limit", &limit);
if (!context || !valid || limit < 1 || limit > 1000
|| (limit_status != MCPC_EC_0 && limit_status != MCPC_EC_ANYDATA_NOTFOUND))
@@ -634,6 +748,7 @@ call_export_view(tiny_mcp_tools_context_t *context, const mcpc_tool_t *tool, mcp
free(cursor);
return;
}
+ start_index = index;
tiny_json_init(&lines);
if (index == 0)
{
@@ -642,24 +757,37 @@ call_export_view(tiny_mcp_tools_context_t *context, const mcpc_tool_t *tool, mcp
for (; index < tiny_store_entry_count(context->store) && emitted < (size_t)limit; ++index)
{
const tiny_store_entry_t *entry = tiny_store_entry(context->store, index);
+ tiny_json_buffer_t item;
+ size_t line_overhead;
if (!entry || entry->deleted
|| (session && session[0] && strcmp(session, entry->message.session_id) != 0))
{
continue;
}
- tiny_mcp_tool_append_message(&lines, &entry->message);
+ tiny_json_init(&item);
+ tiny_mcp_tool_append_message(&item, &entry->message);
+ line_overhead = item.length + 1;
+ if (item.failed || outer_size + lines.length + line_overhead + 2 > READ_RESPONSE_BUDGET)
+ {
+ truncated = true;
+ tiny_json_free(&item);
+ break;
+ }
+ tiny_json_raw_n(&lines, item.data, item.length);
tiny_json_raw(&lines, "\n");
emitted++;
+ tiny_json_free(&item);
}
tiny_json_init(&json);
tiny_json_raw(&json, "{\"jsonl\":");
tiny_json_string(&json, lines.data ? lines.data : "");
- if (index < tiny_store_entry_count(context->store))
+ if (truncated || index < tiny_store_entry_count(context->store))
{
char next[32];
- snprintf(next, sizeof(next), "%zu", index);
+ snprintf(next, sizeof(next), "%zu", index > start_index ? index : start_index + 1);
tiny_json_raw(&json, ",\"nextCursor\":");
tiny_json_string(&json, next);
+ tiny_json_raw(&json, ",\"truncated\":true");
}
tiny_json_raw(&json, "}");
tiny_mcp_tool_add_json(result, &json);
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,