summaryrefslogtreecommitdiff
path: root/src/mcp/tools/read_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/read_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/read_messages.c')
-rw-r--r--src/mcp/tools/read_messages.c232
1 files changed, 180 insertions, 52 deletions
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);