Skip to content

Commit 128a62d

Browse files
GlitterKillclaude
andcommitted
feat: user-visible token savings meter and tool call formatting (v0.9.2)
Surface token savings and tool call results to the user via MCP logging notifications (notifications/message), while keeping JSON responses unchanged for the LLM. Per-call notifications show a compact savings meter bar; end-of-task summaries show session + lifetime stats. New tool-call-formatter module provides human-readable summaries for 17 tools. Removed redundant renderTaskSummary in favor of renderSessionSummary. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 76a6788 commit 128a62d

File tree

8 files changed

+379
-164
lines changed

8 files changed

+379
-164
lines changed

CHANGELOG.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.9.2] - 2026-03-21
9+
10+
### Added
11+
12+
- User-visible token savings meter via MCP logging notifications (`notifications/message`)
13+
- Per-call: compact meter bar showing that call's savings (e.g., `████████░░ 84%`)
14+
- End-of-task: session + lifetime cumulative stats sent when `sdl.usage.stats` is called
15+
- Human-readable tool call formatter (`src/mcp/tool-call-formatter.ts`) with 17 per-tool formatters
16+
- Sends concise text summaries to the user (e.g., `symbol.search "foo" → 5 results`)
17+
- JSON responses remain unchanged for the LLM
18+
- `logging` capability declared in MCP Server capabilities
19+
20+
### Changed
21+
22+
- `renderSessionSummary` now skips lifetime section when no lifetime data exists (avoids misleading zeros when DB is unavailable)
23+
- Session-scope usage stats now fetch lifetime data from LadybugDB for combined summary display
24+
- Usage stats `formattedSummary` is sent as MCP notification to user and stripped from tool response (reduces unnecessary LLM context)
25+
26+
### Removed
27+
28+
- `renderTaskSummary` function (redundant with `renderSessionSummary`)
29+
830
## [0.9.1] - 2026-03-20
931

1032
### Added

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "sdl-mcp",
3-
"version": "0.9.1",
3+
"version": "0.9.2",
44
"description": "Symbol Delta Ledger MCP Server - Cards-first code context for polyglot repositories",
55
"type": "module",
66
"main": "dist/main.js",

src/mcp/savings-meter.ts

Lines changed: 27 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -120,28 +120,6 @@ function maxToolNameWidth(...arrays: ToolUsageEntry[][]): number {
120120
// Summary renderers
121121
// ---------------------------------------------------------------------------
122122

123-
/**
124-
* Render the end-of-task summary (session scope).
125-
*/
126-
export function renderTaskSummary(snapshot: SessionUsageSnapshot): string {
127-
const headerLine = `${BORDER.repeat(2)} Token Savings ${BORDER.repeat(18)}`;
128-
const footerLine = BORDER.repeat(35);
129-
130-
const overallMeter = renderMeter(snapshot.overallSavingsPercent);
131-
const savedStr = formatTokenCount(snapshot.totalSavedTokens);
132-
133-
const lines: string[] = [
134-
headerLine,
135-
`Session: ${snapshot.callCount} calls ${PIPE} ${savedStr} saved ${PIPE} ${overallMeter} ${snapshot.overallSavingsPercent}%`,
136-
"",
137-
];
138-
139-
const nameWidth = maxToolNameWidth(snapshot.toolBreakdown);
140-
lines.push(...renderToolRows(snapshot.toolBreakdown, nameWidth));
141-
lines.push(footerLine);
142-
return lines.join("\n");
143-
}
144-
145123
/**
146124
* Render the end-of-session summary with both session and lifetime sections.
147125
*/
@@ -169,18 +147,20 @@ export function renderSessionSummary(
169147

170148
lines.push(...renderToolRows(session.toolBreakdown, nameWidth));
171149

172-
// --- Lifetime section ---
173-
const ltMeter = renderMeter(lifetime.overallSavingsPercent);
174-
const ltSaved = formatTokenCount(lifetime.totalSavedTokens);
150+
// --- Lifetime section (skip when no data, e.g. DB unavailable) ---
151+
if (lifetime.totalCalls > 0 || lifetime.sessionCount > 0) {
152+
const ltMeter = renderMeter(lifetime.overallSavingsPercent);
153+
const ltSaved = formatTokenCount(lifetime.totalSavedTokens);
175154

176-
lines.push("");
177-
lines.push(
178-
`Lifetime: ${lifetime.totalCalls} calls ${PIPE} ${lifetime.sessionCount} sessions ${PIPE} ${ltSaved} saved ${PIPE} ${ltMeter} ${lifetime.overallSavingsPercent}%`,
179-
);
180-
181-
if (lifetimeToolBreakdown.length > 0) {
182155
lines.push("");
183-
lines.push(...renderToolRows(lifetimeToolBreakdown, nameWidth));
156+
lines.push(
157+
`Lifetime: ${lifetime.totalCalls} calls ${PIPE} ${lifetime.sessionCount} sessions ${PIPE} ${ltSaved} saved ${PIPE} ${ltMeter} ${lifetime.overallSavingsPercent}%`,
158+
);
159+
160+
if (lifetimeToolBreakdown.length > 0) {
161+
lines.push("");
162+
lines.push(...renderToolRows(lifetimeToolBreakdown, nameWidth));
163+
}
184164
}
185165

186166
lines.push(footerLine);
@@ -214,3 +194,18 @@ export function renderLifetimeSummary(
214194
lines.push(footerLine);
215195
return lines.join("\n");
216196
}
197+
198+
/**
199+
* Render a compact savings meter for a single tool call notification.
200+
* Example: "████████░░ 84%"
201+
*/
202+
export function renderUserNotificationLine(
203+
totalSdlTokens: number,
204+
totalRawEquivalent: number,
205+
): string {
206+
const saved = Math.max(0, totalRawEquivalent - totalSdlTokens);
207+
const pct = totalRawEquivalent > 0
208+
? Math.round((saved / totalRawEquivalent) * 100)
209+
: 0;
210+
return `${renderMeter(pct)} ${pct}%`;
211+
}

src/mcp/tool-call-formatter.ts

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
/**
2+
* tool-call-formatter.ts — Human-readable tool call summaries.
3+
*
4+
* Formats SDL-MCP tool calls and results as concise text for user-facing
5+
* MCP logging notifications. The JSON response remains unchanged for the LLM.
6+
*/
7+
8+
// ---------------------------------------------------------------------------
9+
// Helpers
10+
// ---------------------------------------------------------------------------
11+
12+
function truncName(s: string, max = 40): string {
13+
return s.length > max ? s.slice(0, max - 1) + "…" : s;
14+
}
15+
16+
function shortPath(p: string): string {
17+
// Keep last 2 path segments for brevity
18+
const parts = p.replace(/\\/g, "/").split("/");
19+
return parts.length <= 2 ? p : "…/" + parts.slice(-2).join("/");
20+
}
21+
22+
function tok(n: number): string {
23+
if (n < 1000) return String(n);
24+
if (n < 1_000_000) return (n / 1000).toFixed(1) + "k";
25+
return (n / 1_000_000).toFixed(2) + "M";
26+
}
27+
28+
function rng(r: { startLine?: number; endLine?: number } | undefined): string {
29+
if (!r) return "";
30+
return `L${r.startLine ?? "?"}${r.endLine ?? "?"}`;
31+
}
32+
33+
function str(v: unknown): string {
34+
return typeof v === "string" ? v : "";
35+
}
36+
37+
function num(v: unknown): number {
38+
return typeof v === "number" ? v : 0;
39+
}
40+
41+
// ---------------------------------------------------------------------------
42+
// Per-tool formatters
43+
// ---------------------------------------------------------------------------
44+
45+
type Formatter = (
46+
args: Record<string, unknown>,
47+
res: Record<string, unknown>,
48+
) => string | null;
49+
50+
function fmtSymbolSearch(_a: Record<string, unknown>, r: Record<string, unknown>): string | null {
51+
const results = r.results as Array<Record<string, unknown>> | undefined;
52+
if (!results) return null;
53+
const q = str(_a.query);
54+
const lines = [`symbol.search "${q}" → ${results.length} result${results.length !== 1 ? "s" : ""}`];
55+
for (const s of results.slice(0, 5)) {
56+
const name = str(s.name).padEnd(24);
57+
const kind = str(s.kind).padEnd(10);
58+
lines.push(` ${name} ${kind} ${shortPath(str(s.file))}`);
59+
}
60+
if (results.length > 5) lines.push(` …and ${results.length - 5} more`);
61+
return lines.join("\n");
62+
}
63+
64+
function fmtSymbolGetCard(_a: Record<string, unknown>, r: Record<string, unknown>): string | null {
65+
const card = r.card as Record<string, unknown> | undefined;
66+
if (r.notModified) return `symbol.getCard → not modified (ETag hit)`;
67+
if (!card) return null;
68+
const deps = card.deps as Record<string, unknown[]> | undefined;
69+
const imports = deps?.imports?.length ?? 0;
70+
const calls = deps?.calls?.length ?? 0;
71+
return `symbol.getCard → ${str(card.name)} (${str(card.kind)})\n File: ${shortPath(str(card.file))} ${rng(card.range as any)}\n Deps: ${imports} imports, ${calls} calls`;
72+
}
73+
74+
function fmtSymbolGetCards(_a: Record<string, unknown>, r: Record<string, unknown>): string | null {
75+
const cards = r.cards as Array<Record<string, unknown>> | undefined;
76+
if (!cards) return null;
77+
return `symbol.getCards → ${cards.length} card${cards.length !== 1 ? "s" : ""}`;
78+
}
79+
80+
function fmtCodeSkeleton(_a: Record<string, unknown>, r: Record<string, unknown>): string | null {
81+
const file = str(r.file);
82+
const orig = num(r.originalLines);
83+
const est = num(r.estimatedTokens);
84+
const trunc = r.truncated ? " (truncated)" : "";
85+
return `code.getSkeleton → ${shortPath(file)}\n ${orig} → skeleton ${rng(r.range as any)}${trunc} (~${tok(est)} tokens)`;
86+
}
87+
88+
function fmtCodeHotPath(_a: Record<string, unknown>, r: Record<string, unknown>): string | null {
89+
const matched = r.matchedIdentifiers as string[] | undefined;
90+
const requested = (_a.identifiersToFind as string[])?.length ?? 0;
91+
const found = matched?.length ?? 0;
92+
const trunc = r.truncated ? " (truncated)" : "";
93+
return `code.getHotPath → matched ${found}/${requested} identifiers ${rng(r.range as any)}${trunc}\n (~${tok(num(r.estimatedTokens))} tokens)`;
94+
}
95+
96+
function fmtCodeNeedWindow(_a: Record<string, unknown>, r: Record<string, unknown>): string | null {
97+
const approved = r.approved;
98+
const status = approved ? "approved" : "denied";
99+
const downgraded = r.downgradedFrom ? ` (downgraded from ${str(r.downgradedFrom)})` : "";
100+
const est = num(r.estimatedTokens);
101+
if (!approved) {
102+
const next = str((r as any).nextBestAction);
103+
return `code.needWindow → [${status}]${next ? "\n Suggestion: " + next : ""}`;
104+
}
105+
return `code.needWindow → [${status}]${downgraded} ${rng(r.range as any)}\n (~${tok(est)} tokens)`;
106+
}
107+
108+
function fmtSliceBuild(_a: Record<string, unknown>, r: Record<string, unknown>): string | null {
109+
const cards = (r.cards as unknown[])?.length ?? (r.cardRefs as unknown[])?.length ?? 0;
110+
const handle = str(r.sliceHandle);
111+
const spillover = num(r.spilloverCount);
112+
const budget = r.budgetUsed as Record<string, unknown> | undefined;
113+
let line = `slice.build → ${cards} cards`;
114+
if (handle) line += ` (handle: ${handle.slice(0, 8)}…)`;
115+
if (spillover > 0) line += `\n ${spillover} in spillover`;
116+
if (budget) line += `\n Budget: ~${tok(num(budget.estimatedTokens))} tokens used`;
117+
return line;
118+
}
119+
120+
function fmtSliceRefresh(_a: Record<string, unknown>, r: Record<string, unknown>): string | null {
121+
const added = (r.addedCards as unknown[])?.length ?? 0;
122+
const removed = (r.removedSymbolIds as unknown[])?.length ?? 0;
123+
const updated = (r.updatedCards as unknown[])?.length ?? 0;
124+
return `slice.refresh → +${added} -${removed} ~${updated} cards`;
125+
}
126+
127+
function fmtDeltaGet(_a: Record<string, unknown>, r: Record<string, unknown>): string | null {
128+
const changes = (r.changes as unknown[])?.length ?? 0;
129+
const blast = (r.blastRadius as unknown[])?.length ?? 0;
130+
return `delta.get → ${changes} changed symbols${blast > 0 ? `, ${blast} in blast radius` : ""}`;
131+
}
132+
133+
function fmtRepoStatus(_a: Record<string, unknown>, r: Record<string, unknown>): string | null {
134+
const files = num(r.filesIndexed);
135+
const syms = num(r.symbolsIndexed);
136+
const health = num(r.healthScore);
137+
return `repo.status → ${files} files, ${syms} symbols, health ${health}/100`;
138+
}
139+
140+
function fmtRepoOverview(_a: Record<string, unknown>, _r: Record<string, unknown>): string | null {
141+
const level = str(_a.level) || "stats";
142+
return `repo.overview (${level})`;
143+
}
144+
145+
function fmtIndexRefresh(_a: Record<string, unknown>, r: Record<string, unknown>): string | null {
146+
const mode = str(_a.mode) || "incremental";
147+
const files = num(r.filesProcessed ?? r.filesScanned);
148+
return `index.refresh (${mode}) → ${files} files processed`;
149+
}
150+
151+
function fmtChain(_a: Record<string, unknown>, r: Record<string, unknown>): string | null {
152+
const results = r.results as Array<Record<string, unknown>> | undefined;
153+
if (!results) return null;
154+
const ok = results.filter(s => s.status === "ok").length;
155+
const err = results.filter(s => s.status === "error").length;
156+
const total = num(r.totalTokens);
157+
let line = `chain → ${results.length} steps (${ok} ok`;
158+
if (err > 0) line += `, ${err} errors`;
159+
line += `)`;
160+
if (total > 0) line += ` ~${tok(total)} tokens`;
161+
return line;
162+
}
163+
164+
function fmtAgentOrchestrate(_a: Record<string, unknown>, r: Record<string, unknown>): string | null {
165+
const plan = r.plan as Record<string, unknown> | undefined;
166+
const rungs = (plan?.rungs as unknown[])?.length ?? 0;
167+
const status = str(r.status) || "complete";
168+
return `agent.orchestrate [${status}] → ${rungs} rungs`;
169+
}
170+
171+
function fmtMemoryStore(_a: Record<string, unknown>, _r: Record<string, unknown>): string | null {
172+
const title = str(_a.title);
173+
return `memory.store → "${truncName(title)}"`;
174+
}
175+
176+
function fmtMemoryQuery(_a: Record<string, unknown>, r: Record<string, unknown>): string | null {
177+
const memories = (r.memories as unknown[])?.length ?? 0;
178+
return `memory.query → ${memories} result${memories !== 1 ? "s" : ""}`;
179+
}
180+
181+
function fmtPrRisk(_a: Record<string, unknown>, r: Record<string, unknown>): string | null {
182+
const score = num(r.overallRisk ?? r.riskScore);
183+
const items = (r.riskItems as unknown[])?.length ?? 0;
184+
return `pr.risk.analyze → risk ${score}/100, ${items} items`;
185+
}
186+
187+
// ---------------------------------------------------------------------------
188+
// Registry
189+
// ---------------------------------------------------------------------------
190+
191+
const formatters: Record<string, Formatter> = {
192+
"sdl.symbol.search": fmtSymbolSearch,
193+
"sdl.symbol.getCard": fmtSymbolGetCard,
194+
"sdl.symbol.getCards": fmtSymbolGetCards,
195+
"sdl.code.getSkeleton": fmtCodeSkeleton,
196+
"sdl.code.getHotPath": fmtCodeHotPath,
197+
"sdl.code.needWindow": fmtCodeNeedWindow,
198+
"sdl.slice.build": fmtSliceBuild,
199+
"sdl.slice.refresh": fmtSliceRefresh,
200+
"sdl.delta.get": fmtDeltaGet,
201+
"sdl.repo.status": fmtRepoStatus,
202+
"sdl.repo.overview": fmtRepoOverview,
203+
"sdl.index.refresh": fmtIndexRefresh,
204+
"sdl.chain": fmtChain,
205+
"sdl.agent.orchestrate": fmtAgentOrchestrate,
206+
"sdl.memory.store": fmtMemoryStore,
207+
"sdl.memory.query": fmtMemoryQuery,
208+
"sdl.pr.risk.analyze": fmtPrRisk,
209+
};
210+
211+
// ---------------------------------------------------------------------------
212+
// Public API
213+
// ---------------------------------------------------------------------------
214+
215+
/**
216+
* Format a tool call + result as human-readable text for user display.
217+
* Returns null if no formatter is registered for the tool.
218+
*/
219+
export function formatToolCallForUser(
220+
toolName: string,
221+
args: Record<string, unknown>,
222+
result: unknown,
223+
): string | null {
224+
const fmt = formatters[toolName];
225+
if (!fmt) return null;
226+
try {
227+
return fmt(args, (result ?? {}) as Record<string, unknown>);
228+
} catch {
229+
return null;
230+
}
231+
}

src/mcp/tools/usage.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import {
1818
aggregateToolBreakdowns,
1919
} from "../../db/ladybug-usage.js";
2020
import {
21-
renderTaskSummary,
2221
renderSessionSummary,
2322
renderLifetimeSummary,
2423
type AggregateUsage,
@@ -131,9 +130,38 @@ export async function handleUsageStats(
131130
}
132131
}
133132

134-
// Session-only formatted summary (no history fetch needed)
133+
// Session-only: still fetch lifetime for the combined summary
135134
if (request.scope === "session" && response.session) {
136-
response.formattedSummary = renderTaskSummary(response.session);
135+
try {
136+
const conn = await getLadybugConn();
137+
const aggregate = await getAggregateUsage(conn, { repoId: request.repoId });
138+
const snapshots = await getUsageSnapshots(conn, { repoId: request.repoId, limit: 100 });
139+
const allToolEntries = aggregateToolBreakdowns(snapshots.map((s) => s.toolBreakdownJson));
140+
const ltAggregate: AggregateUsage = {
141+
totalSdlTokens: aggregate.totalSdlTokens,
142+
totalRawEquivalent: aggregate.totalRawEquivalent,
143+
totalSavedTokens: aggregate.totalSavedTokens,
144+
overallSavingsPercent: aggregate.overallSavingsPercent,
145+
totalCalls: aggregate.totalCalls,
146+
sessionCount: aggregate.sessionCount,
147+
};
148+
response.formattedSummary = renderSessionSummary(
149+
response.session,
150+
ltAggregate,
151+
allToolEntries,
152+
);
153+
} catch {
154+
// Fallback: session-only without lifetime (DB unavailable)
155+
process.stderr.write(
156+
"[sdl-mcp] Usage stats: could not fetch lifetime data from LadybugDB\n",
157+
);
158+
// Render session section only — omit lifetime to avoid showing misleading zeros
159+
response.formattedSummary = renderSessionSummary(
160+
response.session,
161+
{ totalSdlTokens: 0, totalRawEquivalent: 0, totalSavedTokens: 0, overallSavingsPercent: 0, totalCalls: 0, sessionCount: 0 },
162+
[],
163+
);
164+
}
137165
}
138166

139167
return response;

0 commit comments

Comments
 (0)