Skip to content

Commit 41bb533

Browse files
Default TraceDecay tool output to markdown (#262)
* feat(mcp): default tool responses to markdown with format:json opt-in All MCP tool handlers now route through render::finalize, so the default output is token-efficient markdown and format:"json" returns compact JSON for programmatic consumers. Converted the JSON-only paths: message_search, all lcm_* tools, fact_store/fact_feedback, skill_list/skill_view/ automation_run_artifact_view/hermes_skill_bridge (previously pretty-printed), all edit tools, branch_list, active_project, dashboard, run_affected_tests error paths, git error results, dsm, and tracedecay_retrieve (markdown default returns stored content verbatim; never re-truncates). All converted tools joined FORMAT_CAPABLE_TOOL_NAMES, so their schemas advertise the format param and the generated Hermes bridge plugin keeps injecting format:"json" for its machine contract. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * style: rustfmt handler conversions Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(mcp): purpose-built message_search markdown; lcm_preflight fixes - message_search default output is now a compact markdown list (provider, session+title, role, timestamp, score, readable snippet) instead of generic_md dumping raw metadata_json/source_path/transcript_path blobs into table cells. format:"json" still returns full structured records. - Fix regression: lcm_preflight markdown-default truncation now threads the project root so an oversized payload stores a retrieval handle instead of clipping irreversibly (matches lcm_expand_query). - Fix metrics: JSON truncation envelope reports reversible=handle.is_some() instead of hardcoded true, so failed/absent-handle truncations aren't miscounted as reversible. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mcp): reversible text truncation; bound expand_query floor Address truncation-audit findings so every markdown-default response stays bounded and recoverable: - S4: tracedecay_files and tracedecay_type_hierarchy no longer clip oversized output irreversibly. New render::truncate_text_with_handle stores the full body and returns the markdown truncation envelope with an rh_ handle (delegates to truncated_markdown_with_handle; no duplicated logic). - S3: lcm_expand_query_tool_json's format:"json"+needs_synthesis path could emit an unbounded payload when even the Minimal compaction tier overflowed. New bounded_lcm_expand_query_floor_text enforces a floor: stores a retrieval handle (surfaced as response_handle for the Hermes bridge) and keeps the contract keys (status, needs_synthesis, bounded answer/prompt, rebuilt synthesis_prompt) while zeroing unbounded arrays with *_truncated markers. - S5: LCM preflight/expand_query compaction tiers now call observe_response_truncation so those truncations show up in metrics. - Drop unused LCM_GREP_SNIPPET_CHARS (lcm_grep hits are already bounded). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * style: rustfmt lcm session handlers --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent ad60993 commit 41bb533

18 files changed

Lines changed: 1058 additions & 190 deletions

src/daemon.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3344,7 +3344,8 @@ mod tests {
33443344
"arguments": {
33453345
"provider": "cursor",
33463346
"storage_scope": "hermes_profile",
3347-
"hermes_home": hermes_home
3347+
"hermes_home": hermes_home,
3348+
"format": "json"
33483349
}
33493350
}
33503351
}))

src/mcp/tools/definitions.rs

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -439,9 +439,42 @@ const FORMAT_CAPABLE_TOOL_NAMES: &[&str] = &[
439439
"tracedecay_redundancy",
440440
// memory
441441
"tracedecay_memory_status",
442+
"tracedecay_fact_store",
443+
"tracedecay_fact_feedback",
442444
// workflow
443445
"tracedecay_diagnose",
444446
"tracedecay_run_affected_tests",
447+
// session / LCM
448+
"tracedecay_message_search",
449+
"tracedecay_lcm_status",
450+
"tracedecay_lcm_doctor",
451+
"tracedecay_lcm_load_session",
452+
"tracedecay_lcm_grep",
453+
"tracedecay_lcm_describe",
454+
"tracedecay_lcm_expand",
455+
"tracedecay_lcm_expand_query",
456+
"tracedecay_lcm_session_boundary",
457+
"tracedecay_lcm_preflight",
458+
"tracedecay_lcm_compress",
459+
// skills
460+
"tracedecay_skill_list",
461+
"tracedecay_skill_view",
462+
"tracedecay_automation_run_artifact_view",
463+
"tracedecay_hermes_skill_bridge",
464+
// edit
465+
"tracedecay_str_replace",
466+
"tracedecay_multi_str_replace",
467+
"tracedecay_insert_at",
468+
"tracedecay_insert_at_symbol",
469+
"tracedecay_replace_symbol",
470+
"tracedecay_ast_grep_rewrite",
471+
// git & info
472+
"tracedecay_branch_list",
473+
"tracedecay_active_project",
474+
"tracedecay_storage_status",
475+
// misc
476+
"tracedecay_dashboard",
477+
"tracedecay_retrieve",
445478
];
446479

447480
pub fn format_capable_tool_names() -> &'static [&'static str] {
@@ -1823,8 +1856,8 @@ fn def_dsm() -> ToolDefinition {
18231856
},
18241857
"format": {
18251858
"type": "string",
1826-
"enum": ["stats", "clusters", "matrix"],
1827-
"description": "Output format (default: stats)"
1859+
"enum": ["stats", "clusters", "matrix", "json"],
1860+
"description": "Data shape rendered as markdown: stats, clusters, or matrix (default: stats). Pass 'json' for compact machine-readable JSON of the default stats shape."
18281861
},
18291862
"max_files": {
18301863
"type": "number",

src/mcp/tools/handlers/dashboard.rs

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use serde_json::{json, Value};
1010
use crate::errors::{Result, TraceDecayError};
1111
use crate::tracedecay::TraceDecay;
1212

13-
use super::super::render::truncated_json_envelope_with_handle;
13+
use super::super::render;
1414
use super::super::ToolResult;
1515

1616
use crate::dashboard::{bind_dashboard, build_state, router, DEFAULT_PORT};
@@ -43,11 +43,13 @@ fn validate_mcp_dashboard_host(host: &str) -> Result<&str> {
4343
})
4444
}
4545

46-
fn dashboard_tool_result(cg: &TraceDecay, payload: &Value) -> ToolResult {
47-
let formatted = serde_json::to_string(payload).unwrap_or_default();
46+
fn dashboard_tool_result(cg: &TraceDecay, args: &Value, payload: &Value) -> ToolResult {
47+
let text = render::finalize(Some(cg.project_root()), args, payload, || {
48+
render::generic_md(payload)
49+
});
4850
ToolResult::new(
4951
json!({
50-
"content": [{ "type": "text", "text": truncated_json_envelope_with_handle(Some(cg.project_root()), &formatted) }]
52+
"content": [{ "type": "text", "text": text }]
5153
}),
5254
vec![],
5355
)
@@ -70,7 +72,7 @@ pub(super) async fn handle_dashboard(cg: &TraceDecay, args: Value) -> Result<Too
7072
} else {
7173
json!({ "status": "not_running" })
7274
};
73-
Ok(dashboard_tool_result(cg, &payload))
75+
Ok(dashboard_tool_result(cg, &args, &payload))
7476
}
7577
"start" | "" => {
7678
let host = args
@@ -93,6 +95,7 @@ pub(super) async fn handle_dashboard(cg: &TraceDecay, args: Value) -> Result<Too
9395
// already running — idempotent return
9496
return Ok(dashboard_tool_result(
9597
cg,
98+
&args,
9699
&json!({
97100
"status": "already_running",
98101
"url": handle.url
@@ -127,6 +130,7 @@ pub(super) async fn handle_dashboard(cg: &TraceDecay, args: Value) -> Result<Too
127130

128131
Ok(dashboard_tool_result(
129132
cg,
133+
&args,
130134
&json!({
131135
"status": "started",
132136
"url": url,

src/mcp/tools/handlers/edit.rs

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use serde_json::{json, Value};
77
use crate::errors::{Result, TraceDecayError};
88
use crate::tracedecay::TraceDecay;
99

10+
use super::super::render;
1011
use super::super::ToolResult;
1112

1213
fn missing_required_param(name: &str) -> TraceDecayError {
@@ -28,11 +29,18 @@ fn required_array<'a>(args: &'a Value, name: &str) -> Result<&'a [Value]> {
2829
.ok_or_else(|| missing_required_param(name))
2930
}
3031

31-
fn text_tool_result<T: Serialize>(result: &T, touched_files: Vec<String>) -> ToolResult {
32+
fn text_tool_result<T: Serialize>(
33+
cg: &TraceDecay,
34+
args: &Value,
35+
result: &T,
36+
touched_files: Vec<String>,
37+
) -> ToolResult {
38+
let value = serde_json::to_value(result).unwrap_or_default();
39+
let text = render::finalize(Some(cg.project_root()), args, &value, || {
40+
render::generic_md(&value)
41+
});
3242
ToolResult::new(
33-
json!({
34-
"content": [{ "type": "text", "text": serde_json::to_string(result).unwrap_or_default() }]
35-
}),
43+
json!({ "content": [{ "type": "text", "text": text }] }),
3644
touched_files,
3745
)
3846
}
@@ -44,7 +52,7 @@ pub(super) async fn handle_str_replace(cg: &TraceDecay, args: Value) -> Result<T
4452

4553
let result = cg.str_replace(path, old_str, new_str).await?;
4654
let touched_files = vec![result.file_path.clone()];
47-
Ok(text_tool_result(&result, touched_files))
55+
Ok(text_tool_result(cg, &args, &result, touched_files))
4856
}
4957

5058
pub(super) async fn handle_multi_str_replace(cg: &TraceDecay, args: Value) -> Result<ToolResult> {
@@ -72,7 +80,7 @@ pub(super) async fn handle_multi_str_replace(cg: &TraceDecay, args: Value) -> Re
7280

7381
let result = cg.multi_str_replace(path, &parsed_replacements).await?;
7482
let touched_files = vec![result.file_path.clone()];
75-
Ok(text_tool_result(&result, touched_files))
83+
Ok(text_tool_result(cg, &args, &result, touched_files))
7684
}
7785

7886
pub(super) async fn handle_insert_at(cg: &TraceDecay, args: Value) -> Result<ToolResult> {
@@ -84,7 +92,7 @@ pub(super) async fn handle_insert_at(cg: &TraceDecay, args: Value) -> Result<Too
8492

8593
let result = cg.insert_at(path, anchor, content, before).await?;
8694
let touched_files = vec![result.file_path.clone()];
87-
Ok(text_tool_result(&result, touched_files))
95+
Ok(text_tool_result(cg, &args, &result, touched_files))
8896
}
8997

9098
pub(super) async fn handle_replace_symbol(cg: &TraceDecay, args: Value) -> Result<ToolResult> {
@@ -97,7 +105,7 @@ pub(super) async fn handle_replace_symbol(cg: &TraceDecay, args: Value) -> Resul
97105
} else {
98106
vec![]
99107
};
100-
Ok(text_tool_result(&result, touched_files))
108+
Ok(text_tool_result(cg, &args, &result, touched_files))
101109
}
102110

103111
pub(super) async fn handle_insert_at_symbol(cg: &TraceDecay, args: Value) -> Result<ToolResult> {
@@ -114,7 +122,7 @@ pub(super) async fn handle_insert_at_symbol(cg: &TraceDecay, args: Value) -> Res
114122
} else {
115123
vec![]
116124
};
117-
Ok(text_tool_result(&result, touched_files))
125+
Ok(text_tool_result(cg, &args, &result, touched_files))
118126
}
119127

120128
pub(super) async fn handle_ast_grep_rewrite(cg: &TraceDecay, args: Value) -> Result<ToolResult> {
@@ -128,5 +136,5 @@ pub(super) async fn handle_ast_grep_rewrite(cg: &TraceDecay, args: Value) -> Res
128136
} else {
129137
vec![]
130138
};
131-
Ok(text_tool_result(&result, touched_files))
139+
Ok(text_tool_result(cg, &args, &result, touched_files))
132140
}

src/mcp/tools/handlers/git.rs

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use std::collections::{HashMap, HashSet, VecDeque};
66

77
use serde_json::{json, Value};
88

9-
use super::super::render::{self, truncated_json_envelope_with_handle};
9+
use super::super::render;
1010
use super::super::ToolResult;
1111
use super::support::unique_file_paths;
1212
use crate::errors::{Result, TraceDecayError};
@@ -18,22 +18,20 @@ struct GitFileChange {
1818
status: &'static str,
1919
}
2020

21-
fn project_response_text(cg: &TraceDecay, text: &str) -> String {
22-
truncated_json_envelope_with_handle(Some(cg.project_root()), text)
23-
}
24-
25-
fn git_error_result(cg: &TraceDecay, operation: &str, message: &str) -> ToolResult {
21+
fn git_error_result(cg: &TraceDecay, args: &Value, operation: &str, message: &str) -> ToolResult {
2622
let output = json!({
2723
"error": {
2824
"kind": "git",
2925
"operation": operation,
3026
"message": message,
3127
}
3228
});
33-
let formatted = serde_json::to_string(&output).unwrap_or_default();
29+
let text = render::finalize(Some(cg.project_root()), args, &output, || {
30+
render::generic_md(&output)
31+
});
3432
ToolResult::new(
3533
json!({
36-
"content": [{ "type": "text", "text": project_response_text(cg, &formatted) }]
34+
"content": [{ "type": "text", "text": text }]
3735
}),
3836
vec![],
3937
)
@@ -598,7 +596,7 @@ pub(super) async fn handle_changelog(cg: &TraceDecay, args: Value) -> Result<Too
598596
let changes = match git_diff_file_changes(cg.project_root(), from_ref, to_ref) {
599597
Ok(files) => files,
600598
Err(e) => {
601-
return Ok(git_error_result(cg, "diff", &e));
599+
return Ok(git_error_result(cg, &args, "diff", &e));
602600
}
603601
};
604602
let changed_files: Vec<String> = changes.iter().map(|change| change.path.clone()).collect();
@@ -678,7 +676,7 @@ pub(super) async fn handle_commit_context(cg: &TraceDecay, args: Value) -> Resul
678676
let changed_files = match git_changed_files(cg.project_root(), staged_only) {
679677
Ok(files) => files,
680678
Err(e) => {
681-
return Ok(git_error_result(cg, "status", &e));
679+
return Ok(git_error_result(cg, &args, "status", &e));
682680
}
683681
};
684682

@@ -775,7 +773,7 @@ pub(super) async fn handle_pr_context(cg: &TraceDecay, args: Value) -> Result<To
775773
let changes = match git_diff_file_changes(cg.project_root(), base, head) {
776774
Ok(files) => files,
777775
Err(e) => {
778-
return Ok(git_error_result(cg, "diff", &e));
776+
return Ok(git_error_result(cg, &args, "diff", &e));
779777
}
780778
};
781779
let changed_files: Vec<String> = changes.iter().map(|change| change.path.clone()).collect();
@@ -901,7 +899,7 @@ pub(super) async fn handle_pr_context(cg: &TraceDecay, args: Value) -> Result<To
901899
// ── Cross-branch tools ─────────────────────────────────────────────────
902900

903901
/// Handles `tracedecay_branch_list` tool calls.
904-
pub(super) fn handle_branch_list(cg: &TraceDecay) -> ToolResult {
902+
pub(super) fn handle_branch_list(cg: &TraceDecay, args: &Value) -> ToolResult {
905903
let diagnostics = cg.branch_diagnostics();
906904
let mut result = serde_json::to_value(&diagnostics).unwrap_or(json!({}));
907905
if let Some(object) = result.as_object_mut() {
@@ -911,10 +909,12 @@ pub(super) fn handle_branch_list(cg: &TraceDecay) -> ToolResult {
911909
);
912910
}
913911

914-
let output = serde_json::to_string(&result).unwrap_or_default();
912+
let text = render::finalize(Some(cg.project_root()), args, &result, || {
913+
render::generic_md(&result)
914+
});
915915
ToolResult::new(
916916
json!({
917-
"content": [{ "type": "text", "text": project_response_text(cg, &output) }]
917+
"content": [{ "type": "text", "text": text }]
918918
}),
919919
vec![],
920920
)

src/mcp/tools/handlers/health.rs

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ use crate::graph::queries::GraphQueryManager;
2525
use crate::tracedecay::TraceDecay;
2626
use crate::types::{EdgeKind, NodeKind};
2727

28-
use super::super::render::{self, truncated_json_envelope_with_handle};
28+
use super::super::render;
2929
use super::super::ToolResult;
3030
use super::support::{effective_path, unique_file_paths};
3131

@@ -621,13 +621,15 @@ pub(super) async fn handle_dsm(
621621
}
622622
};
623623

624-
// `dsm` owns its `format` argument (stats/clusters/list/matrix) for data
625-
// shaping, so it stays compact JSON rather than routing through the
626-
// markdown/json `render::finalize` selector.
627-
let formatted = serde_json::to_string(&output).unwrap_or_default();
624+
// `dsm` overloads `format`: stats/clusters/matrix pick the data shape and
625+
// render as markdown; "json" falls through to the default (stats) shape
626+
// and `render::finalize` emits it as compact JSON.
627+
let text = render::finalize(Some(cg.project_root()), &args, &output, || {
628+
render::generic_md(&output)
629+
});
628630
Ok(ToolResult::new(
629631
json!({
630-
"content": [{ "type": "text", "text": truncated_json_envelope_with_handle(Some(cg.project_root()), &formatted) }]
632+
"content": [{ "type": "text", "text": text }]
631633
}),
632634
vec![],
633635
))

src/mcp/tools/handlers/info.rs

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,10 @@ use crate::storage::{ProjectPath, StorageMode, StoreKind};
1313
use crate::tracedecay::{BranchDiagnostics, TraceDecay};
1414
use crate::types::{NodeKind, Visibility};
1515

16-
use super::super::render::{self, truncate_response, truncated_json_envelope_with_handle, Md};
16+
use super::super::render::{self, Md};
1717
use super::super::ToolResult;
1818
use super::support::{effective_path, filter_by_scope, require_node_id, unique_file_paths};
1919

20-
fn project_response_text(cg: &TraceDecay, text: &str) -> String {
21-
truncated_json_envelope_with_handle(Some(cg.project_root()), text)
22-
}
23-
2420
/// Handles `tracedecay_status` tool calls.
2521
pub(super) async fn handle_status(
2622
cg: &TraceDecay,
@@ -254,15 +250,18 @@ fn store_kind_name(kind: &StoreKind) -> &'static str {
254250
/// Handles `tracedecay_active_project` tool calls.
255251
pub(super) fn handle_active_project(
256252
cg: &TraceDecay,
253+
args: &Value,
257254
server_stats: Option<Value>,
258255
scope_prefix: Option<&str>,
259256
) -> ToolResult {
260257
let branch = cg.branch_diagnostics();
261258
let output = active_project_context(cg, &branch, server_stats, scope_prefix);
262-
let formatted = serde_json::to_string(&output).unwrap_or_default();
259+
let text = render::finalize(Some(cg.project_root()), args, &output, || {
260+
render::generic_md(&output)
261+
});
263262
ToolResult::new(
264263
json!({
265-
"content": [{ "type": "text", "text": project_response_text(cg, &formatted) }]
264+
"content": [{ "type": "text", "text": text }]
266265
}),
267266
vec![],
268267
)
@@ -633,7 +632,7 @@ pub(super) async fn handle_files(
633632

634633
Ok(ToolResult::new(
635634
json!({
636-
"content": [{ "type": "text", "text": truncate_response(&output) }]
635+
"content": [{ "type": "text", "text": render::truncate_text_with_handle(Some(cg.project_root()), &output) }]
637636
}),
638637
touched_files,
639638
))
@@ -1536,7 +1535,7 @@ pub(super) async fn handle_type_hierarchy(cg: &TraceDecay, args: Value) -> Resul
15361535

15371536
let touched_files = unique_file_paths(all_files.iter().map(std::string::String::as_str));
15381537
Ok(ToolResult::new(
1539-
json!({"content": [{"type": "text", "text": truncate_response(&output)}]}),
1538+
json!({"content": [{"type": "text", "text": render::truncate_text_with_handle(Some(cg.project_root()), &output)}]}),
15401539
touched_files,
15411540
))
15421541
}

0 commit comments

Comments
 (0)