Usage stats for the obsidian-cli-mcp server (/cli-mcp-tool-stats): calls, estimated tokens, errors and latency per tool, with hints on heavy results, repeat…

Usage stats for the obsidian-cli-mcp server, live in a pane. For each of its tools you see how often it is called, how many tokens its arguments and results cost, how often it fails and how long it takes. Hints point at what to tune: heavy results, repeat calls, recurring errors and schemas that sit in context unused. Totals are kept across sessions, so tools nobody calls and errors that keep coming back stand out over time.
Calls from the main session and from subagents both count. Other MCP servers and the built-in tools are ignored.
At the prompt of a Claude Code terminal session:
/plugin install obsidian-cli-mcp-tool-stats --marketplace marwansaab/Claude-Mods
Answer y to add the marketplace, then choose a scope.
| Command | Does |
|---|---|
/cli-mcp-tool-stats | Opens the stats pane, or closes it when it is open |
/cli-mcp-tool-stats report | Writes the full report into the conversation as Markdown, so Claude can read it and act on it |
/cli-mcp-tool-stats reset | Starts this session's figures over; what was counted so far stays in the history |
/cli-mcp-tool-stats forget | Clears the history of earlier sessions |
Press the tab buttons, or their hotkeys while the pane has focus:
r re-reads the schema costs. y copies the whole report as Markdown.
| Hint | When it shows |
|---|---|
| Heavy results | A tool's results average more than the threshold, or one result is four times it. Says what to ask for instead: for read, outline and then read_heading. |
| Repeat calls | The same tool called with the same arguments more than once, and the tokens the repeats re-read. |
| Error patterns | A tool fails 20% of the time or more (three calls at least), or hits the same error twice. |
| Unused schemas | Tool schemas loaded into context but not called this session, with their token cost per request. After three sessions, tools never called at all. |
| Slow calls | A tool averages longer than the slow threshold. |
| Permission denials | A tool was denied, so an allow or deny rule would save the prompt. |
| Setting | Default | Meaning |
|---|---|---|
server | obsidian-cli-mcp | The server tracked, as /mcp names it |
heavyResultTokens | 2000 | Average result size, in tokens, above which a tool counts as heavy |
slowCallMs | 3000 | Average time, in ms, above which a tool counts as slow |
keepHistory | true | Keep per-tool totals and error patterns across sessions |
tool.call hook wraps each call to mcp__obsidian-cli-mcp__* (a plugin-provided copy of the server, mcp__plugin_…_obsidian-cli-mcp__*, matches too). It records the arguments, the result text the model read, whether the call errored or was denied, and which agent made it. The hook never changes the call, and a failure inside it is swallowed.duration_ms, which leaves out time spent waiting on a permission prompt. When that is missing the mod falls back to wall time.$.session.usage({ breakdown: 'summary' })), read when the pane opens and on r.$.state. The history is written to the mod's $.store once a minute and when the session ends. Each write replaces the whole record, so a reload never counts a call twice. A /clear folds the session into the history and starts again.alt text and no animation.hooks/register.tsx 345 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CallRecord, StatsSort, StatsTab, StatsView } from '../types'
5import {
6 MAX_RECENT,
7 addError,
8 addRepeat,
9 addToTotals,
10 argsOf,
11 chartSvg,
12 cut,
13 emptyHistory,
14 errorsMarkdown,
15 estTokens,
16 firstLine,
17 hintsMarkdown,
18 hintsOf,
19 historyMarkdown,
20 mergeHistory,
21 normalizeServer,
22 parseHistory,
23 reportMarkdown,
24 resultText,
25 shortNameOf,
26 stableStringify,
27 summaryLine,
28 toolsTable,
29} from './stats'
30
31const PLUGIN = 'obsidian-cli-mcp-tool-stats'
32const COMMAND = 'cli-mcp-tool-stats'
33const PANE = PLUGIN
34const STORE_KEY = 'history'
35
36const tools = atom({ plugin: 'obsidian-cli-mcp-tool-stats', key: 'tools' } as const, {})
37const recent = atom({ plugin: 'obsidian-cli-mcp-tool-stats', key: 'recent' } as const, [])
38const errors = atom({ plugin: 'obsidian-cli-mcp-tool-stats', key: 'errors' } as const, [])
39const repeats = atom({ plugin: 'obsidian-cli-mcp-tool-stats', key: 'repeats' } as const, {})
40const pendingRunMs = atom({ plugin: 'obsidian-cli-mcp-tool-stats', key: 'pendingRunMs' } as const, {})
41const schemas = atom({ plugin: 'obsidian-cli-mcp-tool-stats', key: 'schemas' } as const, [])
42const schemasAt = atom({ plugin: 'obsidian-cli-mcp-tool-stats', key: 'schemasAt' } as const, 0)
43const baseline = atom({ plugin: 'obsidian-cli-mcp-tool-stats', key: 'baseline' } as const, emptyHistory())
44const isBaselineLoaded = atom({ plugin: 'obsidian-cli-mcp-tool-stats', key: 'isBaselineLoaded' } as const, false)
45const view = atom({ plugin: 'obsidian-cli-mcp-tool-stats', key: 'view' } as const, { tab: 'tools', sort: 'tokens', note: '' } as StatsView)
46
47const TABS: { tab: StatsTab; label: string; hotkey: string }[] = [
48 { tab: 'tools', label: 'Tools', hotkey: '1' },
49 { tab: 'errors', label: 'Errors', hotkey: '2' },
50 { tab: 'hints', label: 'Hints', hotkey: '3' },
51 { tab: 'history', label: 'History', hotkey: '4' },
52]
53
54const SORTS: { sort: StatsSort; label: string; hotkey: string }[] = [
55 { sort: 'tokens', label: 'Tokens', hotkey: 't' },
56 { sort: 'calls', label: 'Calls', hotkey: 'c' },
57 { sort: 'errors', label: 'Errors', hotkey: 'e' },
58 { sort: 'time', label: 'Time', hotkey: 'm' },
59]
60
61/** The stats are an observer: nothing they do may fail the call, the turn or the session they watch. */
62async function safely(work: () => Promise<unknown>): Promise<void> {
63 try {
64 await work()
65 } catch {
66 // Dropped: a missed update costs one call's figures, never the call.
67 }
68}
69
70type Settings = { server: string; heavyTokens: number; slowMs: number; keepHistory: boolean }
71
72async function firstAtOf($: EngineInterface): Promise<number> {
73 return (await read($, recent))[0]?.at ?? (await $.clock.now())
74}
75
76/** Every session before this one plus this one, as the store should hold it now. */
77async function mergedHistory($: EngineInterface) {
78 return mergeHistory(await read($, baseline), await read($, tools), await read($, errors), await firstAtOf($))
79}
80
81// Writes the whole merged picture, so a write repeated after a reload never counts a call twice.
82async function flush($: EngineInterface, cfg: Settings): Promise<void> {
83 if (!cfg.keepHistory) return
84 const merged = await mergedHistory($)
85 if (merged.sessions > 0) await $.store.set(STORE_KEY, merged)
86}
87
88// Folds this session into the baseline and starts its figures over: a /clear, or the reset command.
89async function startOver($: EngineInterface, cfg: Settings): Promise<void> {
90 await flush($, cfg)
91 const merged = await mergedHistory($)
92 await update($, baseline, () => merged)
93 await update($, tools, () => ({}))
94 await update($, recent, () => [])
95 await update($, errors, () => [])
96 await update($, repeats, () => ({}))
97 await update($, pendingRunMs, () => ({}))
98}
99
100async function refreshSchemas($: EngineInterface, cfg: Settings): Promise<void> {
101 const usage = await $.session.usage({ breakdown: 'summary' })
102 const list = (usage.context.breakdown?.mcpTools ?? []).flatMap(t => {
103 const tool = shortNameOf(t.name, cfg.server)
104 return tool === undefined ? [] : [{ tool, tokens: t.tokens, isLoaded: t.isLoaded }]
105 })
106 const at = await $.clock.now()
107 await update($, schemas, () => list)
108 await update($, schemasAt, () => at)
109}
110
111async function openPane($: EngineInterface, cfg: Settings): Promise<void> {
112 await $.ui.open({ id: PANE, title: 'obsidian-cli-mcp tool stats' })
113 await safely(() => refreshSchemas($, cfg))
114}
115
116async function reportOf($: EngineInterface, cfg: Settings): Promise<string> {
117 return reportMarkdown({
118 server: cfg.server,
119 tools: await read($, tools),
120 errors: await read($, errors),
121 repeats: await read($, repeats),
122 schemas: await read($, schemas),
123 schemasAt: await read($, schemasAt),
124 history: await read($, baseline),
125 recent: await read($, recent),
126 firstAt: await firstAtOf($),
127 now: await $.clock.now(),
128 heavyTokens: cfg.heavyTokens,
129 slowMs: cfg.slowMs,
130 })
131}
132
133// The engine's own execution time, permission prompt excluded; it lands before the call's hook finishes.
134async function noteRunMs(
135 $: EngineInterface,
136 cfg: Settings,
137 e: { tool_name: string; tool_use_id: string; duration_ms?: number },
138): Promise<void> {
139 if (e.duration_ms === undefined || shortNameOf(e.tool_name, cfg.server) === undefined) return
140 const ms = e.duration_ms
141 await update($, pendingRunMs, p => ({ ...p, [e.tool_use_id]: ms }))
142}
143
144export const register: Register = (on, options) => {
145 const server = normalizeServer(String(options.server ?? '') || 'obsidian-cli-mcp')
146 const heavyTokens = Number(options.heavyResultTokens) > 0 ? Number(options.heavyResultTokens) : 2000
147 const slowMs = Number(options.slowCallMs) > 0 ? Number(options.slowCallMs) : 3000
148 const keepHistory = options.keepHistory !== false
149
150 const cfg: Settings = { server, heavyTokens, slowMs, keepHistory }
151 const toolOf = (name: string) => shortNameOf(name, server)
152
153 on('session.start', async ($, e, next) => {
154 const started = await next(e)
155 await safely(async () => {
156 await $.command.register({
157 name: COMMAND,
158 description: 'Show or hide obsidian-cli-mcp tool stats: calls, tokens, errors, latency and optimisation hints',
159 argumentHint: '[reset | forget | report]',
160 })
161 // A reload fires session.start again: read the store once, or this session's own flushes would count twice.
162 if (keepHistory && !(await read($, isBaselineLoaded))) {
163 const stored = parseHistory(await $.store.get(STORE_KEY))
164 await update($, baseline, () => stored)
165 await update($, isBaselineLoaded, () => true)
166 }
167 $.clock.every(60_000, () => void safely(() => flush($, cfg)))
168 })
169 return started
170 })
171
172 on('session.end', async ($, e, next) => {
173 await safely(() => (e.reason === 'clear' ? startOver($, cfg) : flush($, cfg)))
174 return next(e)
175 }).catch(($, e, next) => next(e))
176
177 on('command.run', { command: COMMAND }, async ($, e) => {
178 const arg = e.args.trim().toLowerCase()
179 if (arg === 'reset') {
180 await startOver($, cfg)
181 return { text: 'obsidian-cli-mcp tool stats: this session starts over (its figures so far are kept in the history).' }
182 }
183 if (arg === 'forget') {
184 await update($, baseline, () => emptyHistory())
185 await $.store.delete(STORE_KEY)
186 return { text: 'obsidian-cli-mcp tool stats: the history of earlier sessions is forgotten. This session is still counted.' }
187 }
188 if (arg === 'report') {
189 await safely(() => refreshSchemas($, cfg))
190 return { text: await reportOf($, cfg) }
191 }
192 const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
193 if (isOpen) {
194 await $.ui.close({ id: PANE })
195 return { text: 'obsidian-cli-mcp tool stats closed.' }
196 }
197 await openPane($, cfg)
198 return { text: 'obsidian-cli-mcp tool stats opened.' }
199 }).catch(() => ({ text: 'obsidian-cli-mcp tool stats could not do that just now.' }))
200
201 on('classic.PostToolUse', async ($, e, next) => {
202 await safely(() => noteRunMs($, cfg, e))
203 return next(e)
204 }).catch(($, e, next) => next(e))
205
206 on('classic.PostToolUseFailure', async ($, e, next) => {
207 await safely(() => noteRunMs($, cfg, e))
208 return next(e)
209 }).catch(($, e, next) => next(e))
210
211 on('tool.call', async ($, e, next) => {
212 const tool = toolOf(e.tool)
213 if (tool === undefined) return next(e)
214
215 const startedAt = await $.clock.now()
216 const ran = await next(e)
217
218 await safely(async () => {
219 const at = await $.clock.now()
220 const id = e.tool_use_id ?? `${tool}-${at}`
221 const argsFull = stableStringify(argsOf(e as Record<string, unknown>))
222 const pending = await read($, pendingRunMs)
223 const isDenied = ran.deny !== undefined
224 const isError = ran.isError === true
225 const text = resultText(ran)
226 const call: CallRecord = {
227 id,
228 tool,
229 at,
230 wallMs: Math.max(0, at - startedAt),
231 runMs: pending[id],
232 inTokens: estTokens(argsFull),
233 outTokens: estTokens(text),
234 isError,
235 isDenied,
236 isSubagent: e.agentId !== undefined,
237 args: cut(argsFull, 160),
238 error: isDenied || isError ? firstLine(text) || (isDenied ? 'denied' : 'error') : undefined,
239 }
240 if (call.runMs !== undefined)
241 await update($, pendingRunMs, p => {
242 const rest = { ...p }
243 delete rest[id]
244 return rest
245 })
246 await update($, tools, all => ({ ...all, [tool]: addToTotals(all[tool], call) }))
247 await update($, recent, list => [...list, call].slice(-MAX_RECENT))
248 if (isError) await update($, errors, groups => addError(groups, call))
249 await update($, repeats, groups => addRepeat(groups, call, argsFull))
250 })
251
252 return ran
253 }).catch(($, e, next) => next(e))
254
255 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
256 const ui = $.ui.resolve(e)
257 const { Box, Text, Button, Markdown } = ui
258 const v = await read($, view)
259 const all = await read($, tools)
260 const errs = await read($, errors)
261 const rec = await read($, recent)
262 const reps = await read($, repeats)
263 const sch = await read($, schemas)
264 const schAt = await read($, schemasAt)
265 const hist = await read($, baseline)
266 const now = await $.clock.now()
267 const columns = e.props.bodyColumns || 80
268
269 const hints = hintsOf({ tools: all, errors: errs, repeats: reps, schemas: sch, history: hist, heavyTokens, slowMs })
270 const setView = (patch: Partial<StatsView>) => update($, view, prev => ({ ...prev, note: '', ...patch }))
271
272 const tabs = (
273 <Box flexDirection="row" gap={1} flexWrap="wrap">
274 {TABS.map(t => (
275 <Button
276 key={`tab-${t.tab}`}
277 label={t.tab === 'hints' && hints.length ? `${t.label} (${hints.length})` : t.tab === 'errors' && errs.length ? `${t.label} (${errs.reduce((n, g) => n + g.count, 0)})` : t.label}
278 hotkey={t.hotkey}
279 variant={v.tab === t.tab ? 'primary' : undefined}
280 dimColor={v.tab === t.tab ? undefined : true}
281 onPress={() => setView({ tab: t.tab })}
282 />
283 ))}
284 </Box>
285 )
286
287 const actions = (
288 <Box flexDirection="row" gap={1} flexWrap="wrap">
289 {v.tab === 'tools' &&
290 SORTS.map(s => (
291 <Button
292 key={`sort-${s.sort}`}
293 label={`${v.sort === s.sort ? '▾ ' : ''}${s.label}`}
294 hotkey={s.hotkey}
295 plain
296 dimColor={v.sort === s.sort ? undefined : true}
297 onPress={() => setView({ sort: s.sort })}
298 />
299 ))}
300 <Button key="refresh" label="Refresh" hotkey="r" plain dimColor onPress={() => safely(() => refreshSchemas($, cfg))} />
301 <Button
302 key="copy"
303 label="Copy report"
304 hotkey="y"
305 plain
306 dimColor
307 onPress={async press => {
308 const copied = await $.ui.copy({ text: await reportOf($, cfg), surface: press.surface })
309 await update($, view, prev => ({ ...prev, note: copied.isCopied ? 'Report copied as Markdown.' : `Could not copy: ${copied.reason}` }))
310 }}
311 />
312 </Box>
313 )
314
315 let body: string
316 if (v.tab === 'tools') body = toolsTable(all, v.sort, columns < 90, now)
317 else if (v.tab === 'errors') body = errorsMarkdown(errs, rec, now)
318 else if (v.tab === 'hints') body = hintsMarkdown(hints, schAt)
319 else body = keepHistory ? historyMarkdown(hist, all, sch, rec[0]?.at ?? now, now) : '_History is off in this mod’s settings._'
320
321 const chart =
322 v.tab === 'tools' && 'Svg' in ui && rec.length > 0
323 ? (() => {
324 const { Svg } = ui
325 const width = Math.max(260, Math.min(900, columns * 8 - 24))
326 const c = chartSvg(all, v.sort, width)
327 return <Svg source={c.svg} alt={c.alt} width={width} height={c.height} />
328 })()
329 : null
330
331 return (
332 <Box flexDirection="column" gap={1}>
333 <Text dimColor wrap="wrap">
334 {summaryLine(all)}
335 </Text>
336 {tabs}
337 {actions}
338 {v.note ? <Text color="green">{v.note}</Text> : null}
339 {chart}
340 <Markdown text={body} />
341 </Box>
342 )
343 })
344}
345hooks/stats.ts 574 lines1import type { CallRecord, ErrorGroup, History, RepeatGroup, SchemaCost, StatsSort, ToolTotals } from '../types'
2
3export const MAX_RECENT = 200
4export const MAX_REPEATS = 400
5export const MAX_ERROR_GROUPS = 60
6
7/** Keys `tool.call` carries beside the tool's own arguments. */
8const RESERVED = new Set(['tool', 'tool_use_id', 'agentId', 'consent'])
9
10export const emptyHistory = (): History => ({ sessions: 0, since: 0, tools: {}, errors: [] })
11
12export const emptyTotals = (): ToolTotals => ({
13 calls: 0,
14 errors: 0,
15 denied: 0,
16 inTokens: 0,
17 outTokens: 0,
18 maxOutTokens: 0,
19 runMs: 0,
20 maxRunMs: 0,
21 timedCalls: 0,
22 subagentCalls: 0,
23 lastAt: 0,
24})
25
26/** MCP tool names keep letters, digits, `_` and `-`; anything else in a server's name becomes `_`. */
27export const normalizeServer = (server: string): string => server.trim().replace(/[^a-zA-Z0-9_-]/g, '_')
28
29/**
30 * The tool's short name when it belongs to the tracked server, else undefined.
31 * Matches `mcp__<server>__<tool>` and a plugin-provided server's longer prefix (`mcp__plugin_x_<server>__<tool>`).
32 */
33export function shortNameOf(tool: string, server: string): string | undefined {
34 if (!tool.startsWith('mcp__') || server === '') return undefined
35 const marker = `${server}__`
36 const at = tool.indexOf(marker, 5)
37 if (at < 0) return undefined
38 if (at !== 5 && tool[at - 1] !== '_') return undefined
39 const name = tool.slice(at + marker.length)
40 return name === '' ? undefined : name
41}
42
43/** A rough token count: about four characters a token, as English and JSON run. */
44export const estTokens = (text: string): number => (text ? Math.ceil(text.length / 4) : 0)
45
46/** JSON with sorted keys, so the same arguments always read the same. */
47export function stableStringify(value: unknown): string {
48 if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null'
49 if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`
50 const obj = value as Record<string, unknown>
51 const keys = Object.keys(obj)
52 .filter(k => obj[k] !== undefined)
53 .sort()
54 return `{${keys.map(k => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(',')}}`
55}
56
57/** The tool's own arguments, without the keys the engine adds. */
58export function argsOf(e: Record<string, unknown>): Record<string, unknown> {
59 const out: Record<string, unknown> = {}
60 for (const [k, v] of Object.entries(e)) if (!RESERVED.has(k)) out[k] = v
61 return out
62}
63
64/** What the model read back from the call. */
65export function resultText(ran: { deny?: string; text?: string; result?: unknown }): string {
66 if (ran.deny !== undefined) return ran.deny
67 if (typeof ran.text === 'string') return ran.text
68 if (typeof ran.result === 'string') return ran.result
69 try {
70 return JSON.stringify(ran.result) ?? ''
71 } catch {
72 return ''
73 }
74}
75
76export const cut = (text: string, max: number): string => (text.length > max ? `${text.slice(0, max - 1)}…` : text)
77
78export const firstLine = (text: string): string => cut(text.trim().split(/\r?\n/)[0] ?? '', 200)
79
80/** An error message with its particulars masked, so errors that differ only in a path or a number group together. */
81export function maskError(message: string): string {
82 return cut(
83 firstLine(message)
84 .replace(/"[^"]*"|'[^']*'|`[^`]*`/g, '"…"')
85 .replace(/(?:[A-Za-z]:)?(?:[\w.-]*[\\/])+[\w.-]+/g, '<path>')
86 .replace(/\b[\w-]+\.(?:md|canvas|base|json|png|jpe?g|pdf)\b/gi, '<file>')
87 .replace(/\d+/g, '#')
88 .replace(/\s+/g, ' '),
89 120,
90 )
91}
92
93export function addToTotals(t: ToolTotals | undefined, c: CallRecord): ToolTotals {
94 const base = t ?? emptyTotals()
95 const ms = c.runMs ?? c.wallMs
96 return {
97 calls: base.calls + 1,
98 errors: base.errors + (c.isError ? 1 : 0),
99 denied: base.denied + (c.isDenied ? 1 : 0),
100 inTokens: base.inTokens + c.inTokens,
101 outTokens: base.outTokens + c.outTokens,
102 maxOutTokens: Math.max(base.maxOutTokens, c.outTokens),
103 runMs: base.runMs + (c.isDenied ? 0 : ms),
104 maxRunMs: Math.max(base.maxRunMs, c.isDenied ? 0 : ms),
105 timedCalls: base.timedCalls + (c.isDenied ? 0 : 1),
106 subagentCalls: base.subagentCalls + (c.isSubagent ? 1 : 0),
107 lastAt: Math.max(base.lastAt, c.at),
108 }
109}
110
111export function mergeTotals(a: ToolTotals | undefined, b: ToolTotals | undefined): ToolTotals {
112 const x = a ?? emptyTotals()
113 const y = b ?? emptyTotals()
114 return {
115 calls: x.calls + y.calls,
116 errors: x.errors + y.errors,
117 denied: x.denied + y.denied,
118 inTokens: x.inTokens + y.inTokens,
119 outTokens: x.outTokens + y.outTokens,
120 maxOutTokens: Math.max(x.maxOutTokens, y.maxOutTokens),
121 runMs: x.runMs + y.runMs,
122 maxRunMs: Math.max(x.maxRunMs, y.maxRunMs),
123 timedCalls: x.timedCalls + y.timedCalls,
124 subagentCalls: x.subagentCalls + y.subagentCalls,
125 lastAt: Math.max(x.lastAt, y.lastAt),
126 }
127}
128
129export function addError(groups: readonly ErrorGroup[], c: CallRecord): ErrorGroup[] {
130 if (!c.isError || c.error === undefined) return [...groups]
131 const pattern = maskError(c.error)
132 const found = groups.find(g => g.tool === c.tool && g.pattern === pattern)
133 const next = found
134 ? groups.map(g => (g === found ? { ...g, count: g.count + 1, sample: c.error ?? g.sample, lastAt: c.at } : g))
135 : [...groups, { tool: c.tool, pattern, sample: c.error, count: 1, lastAt: c.at }]
136 return trimErrors(next)
137}
138
139const trimErrors = (groups: ErrorGroup[]): ErrorGroup[] =>
140 groups.length <= MAX_ERROR_GROUPS ? groups : [...groups].sort((a, b) => b.count - a.count || b.lastAt - a.lastAt).slice(0, MAX_ERROR_GROUPS)
141
142export function mergeErrors(a: readonly ErrorGroup[], b: readonly ErrorGroup[]): ErrorGroup[] {
143 const out = a.map(g => ({ ...g }))
144 for (const g of b) {
145 const found = out.find(o => o.tool === g.tool && o.pattern === g.pattern)
146 if (found) {
147 found.count += g.count
148 if (g.lastAt >= found.lastAt) {
149 found.lastAt = g.lastAt
150 found.sample = g.sample
151 }
152 } else out.push({ ...g })
153 }
154 return trimErrors(out)
155}
156
157export const repeatKey = (tool: string, args: string): string => `${tool} ${cut(args, 400)}`
158
159export function addRepeat(groups: Readonly<Record<string, RepeatGroup>>, c: CallRecord, argsFull: string): Record<string, RepeatGroup> {
160 if (c.isDenied) return { ...groups }
161 const key = repeatKey(c.tool, argsFull)
162 const found = groups[key]
163 const next: Record<string, RepeatGroup> = {
164 ...groups,
165 [key]: found
166 ? { ...found, count: found.count + 1, wastedTokens: found.wastedTokens + c.outTokens }
167 : { tool: c.tool, args: c.args, count: 1, wastedTokens: 0 },
168 }
169 const keys = Object.keys(next)
170 if (keys.length <= MAX_REPEATS) return next
171 // Over the bound: forget the oldest calls that never repeated.
172 for (const k of keys) {
173 if (Object.keys(next).length <= MAX_REPEATS) break
174 if (next[k]?.count === 1 && k !== key) delete next[k]
175 }
176 return next
177}
178
179/** Every session before this one plus this one, as the store keeps it. */
180export function mergeHistory(
181 baseline: History,
182 tools: Readonly<Record<string, ToolTotals>>,
183 errors: readonly ErrorGroup[],
184 firstAt: number,
185): History {
186 const hasCalls = Object.values(tools).some(t => t.calls > 0)
187 if (!hasCalls) return baseline
188 const merged: Record<string, ToolTotals> = { ...baseline.tools }
189 for (const [name, t] of Object.entries(tools)) merged[name] = mergeTotals(merged[name], t)
190 return {
191 sessions: baseline.sessions + 1,
192 since: baseline.since || firstAt,
193 tools: merged,
194 errors: mergeErrors(baseline.errors, errors),
195 }
196}
197
198/** Reads what the store held, tolerating anything an older version or a hand edit left there. */
199export function parseHistory(raw: unknown): History {
200 if (raw === null || typeof raw !== 'object') return emptyHistory()
201 const h = raw as Partial<History>
202 const tools: Record<string, ToolTotals> = {}
203 if (h.tools && typeof h.tools === 'object')
204 for (const [name, t] of Object.entries(h.tools)) if (t && typeof t === 'object') tools[name] = mergeTotals(emptyTotals(), t)
205 return {
206 sessions: Number(h.sessions) || 0,
207 since: Number(h.since) || 0,
208 tools,
209 errors: Array.isArray(h.errors) ? h.errors.filter(g => g && typeof g.tool === 'string' && typeof g.pattern === 'string') : [],
210 }
211}
212
213// ---------------------------------------------------------------- figures
214
215export const avg = (sum: number, n: number): number => (n > 0 ? sum / n : 0)
216
217export function fmtTok(n: number): string {
218 if (n < 1000) return String(Math.round(n))
219 if (n < 10_000) return `${(n / 1000).toFixed(1)}k`
220 if (n < 1_000_000) return `${Math.round(n / 1000)}k`
221 return `${(n / 1_000_000).toFixed(1)}M`
222}
223
224export function fmtMs(ms: number): string {
225 if (ms < 1000) return `${Math.round(ms)} ms`
226 if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`
227 return `${Math.floor(ms / 60_000)} m ${Math.round((ms % 60_000) / 1000)} s`
228}
229
230export const fmtPct = (part: number, whole: number): string => (whole > 0 ? `${Math.round((part / whole) * 100)}%` : '0%')
231
232export function fmtAgo(at: number, now: number): string {
233 if (!at) return '—'
234 const s = Math.max(0, Math.round((now - at) / 1000))
235 if (s < 60) return `${s}s ago`
236 if (s < 3600) return `${Math.floor(s / 60)}m ago`
237 if (s < 86_400) return `${Math.floor(s / 3600)}h ago`
238 return `${Math.floor(s / 86_400)}d ago`
239}
240
241export const fmtDate = (at: number): string => (at ? new Date(at).toISOString().slice(0, 10) : '—')
242
243export function sumTotals(tools: Readonly<Record<string, ToolTotals>>): ToolTotals {
244 return Object.values(tools).reduce<ToolTotals>((acc, t) => mergeTotals(acc, t), emptyTotals())
245}
246
247const sortValue = (t: ToolTotals, sort: StatsSort): number =>
248 sort === 'calls' ? t.calls : sort === 'tokens' ? t.inTokens + t.outTokens : sort === 'errors' ? t.errors + t.denied : t.runMs
249
250export function sortedTools(tools: Readonly<Record<string, ToolTotals>>, sort: StatsSort): [string, ToolTotals][] {
251 return Object.entries(tools).sort(([an, a], [bn, b]) => sortValue(b, sort) - sortValue(a, sort) || b.calls - a.calls || an.localeCompare(bn))
252}
253
254/** One line for the pane's head and the report's. */
255export function summaryLine(tools: Readonly<Record<string, ToolTotals>>): string {
256 const t = sumTotals(tools)
257 if (t.calls === 0) return 'No calls yet this session.'
258 const used = Object.values(tools).filter(x => x.calls > 0).length
259 return [
260 `${t.calls} call${t.calls === 1 ? '' : 's'}`,
261 `${used} tool${used === 1 ? '' : 's'}`,
262 `≈${fmtTok(t.outTokens)} tok read`,
263 `≈${fmtTok(t.inTokens)} tok sent`,
264 `${t.errors} error${t.errors === 1 ? '' : 's'} (${fmtPct(t.errors, t.calls)})`,
265 t.denied ? `${t.denied} denied` : '',
266 `avg ${fmtMs(avg(t.runMs, t.timedCalls))}`,
267 t.subagentCalls ? `${fmtPct(t.subagentCalls, t.calls)} from subagents` : '',
268 ]
269 .filter(Boolean)
270 .join(' · ')
271}
272
273// ---------------------------------------------------------------- hints
274
275export type HintKind = 'heavy' | 'schema' | 'errors' | 'repeat' | 'slow' | 'denied'
276
277export type Hint = {
278 kind: HintKind
279 /** 3 is the most worth acting on. */
280 weight: 1 | 2 | 3
281 tool?: string
282 text: string
283}
284
285export type HintInput = {
286 tools: Readonly<Record<string, ToolTotals>>
287 errors: readonly ErrorGroup[]
288 repeats: Readonly<Record<string, RepeatGroup>>
289 schemas: readonly SchemaCost[]
290 history: History
291 heavyTokens: number
292 slowMs: number
293}
294
295/** What to ask for instead when a tool's results run large; the obsidian-cli-mcp tools by name, a general line otherwise. */
296export function lighterAlternative(tool: string): string {
297 if (tool === 'read') return 'call `outline` first, then `read_heading` for just the section needed'
298 if (/search|query|similar/.test(tool)) return 'narrow it to a folder or ask for fewer results, if the tool takes a limit'
299 if (tool === 'files' || tool === 'paths' || tool === 'tag' || tool === 'properties') return 'filter it to a folder or a pattern'
300 if (/base/.test(tool)) return 'query fewer rows or properties'
301 if (tool === 'backlinks' || tool === 'links') return 'ask about one note at a time'
302 return 'ask for less: a narrower scope, a limit, or a summary'
303}
304
305export const HINT_TITLES: Record<HintKind, string> = {
306 heavy: 'Heavy results',
307 repeat: 'Repeat calls',
308 errors: 'Error patterns',
309 schema: 'Unused schemas',
310 slow: 'Slow calls',
311 denied: 'Permission denials',
312}
313
314export function hintsOf(input: HintInput): Hint[] {
315 const { tools, errors, repeats, schemas, history, heavyTokens, slowMs } = input
316 const hints: Hint[] = []
317
318 for (const [tool, t] of Object.entries(tools)) {
319 if (t.calls === 0) continue
320 const avgOut = avg(t.outTokens, t.calls)
321
322 if ((t.calls >= 2 && avgOut >= heavyTokens) || t.maxOutTokens >= heavyTokens * 4) {
323 hints.push({
324 kind: 'heavy',
325 weight: t.outTokens >= heavyTokens * 10 ? 3 : 2,
326 tool,
327 text: `\`${tool}\` results average ≈${fmtTok(avgOut)} tok (largest ≈${fmtTok(t.maxOutTokens)}, ≈${fmtTok(t.outTokens)} in all). To read less, ${lighterAlternative(tool)}.`,
328 })
329 }
330
331 if (t.calls >= 3 && t.errors / t.calls >= 0.2) {
332 const top = errors.filter(g => g.tool === tool).sort((a, b) => b.count - a.count)[0]
333 hints.push({
334 kind: 'errors',
335 weight: t.errors / t.calls >= 0.5 ? 3 : 2,
336 tool,
337 text: `\`${tool}\` fails ${fmtPct(t.errors, t.calls)} of the time (${t.errors} of ${t.calls})${top ? `, mostly: “${top.pattern}” ×${top.count}` : ''}. Worth a fix in the server or a clearer description of its arguments.`,
338 })
339 } else if (t.errors > 0) {
340 const top = errors.filter(g => g.tool === tool && g.count >= 2).sort((a, b) => b.count - a.count)[0]
341 if (top)
342 hints.push({
343 kind: 'errors',
344 weight: 1,
345 tool,
346 text: `\`${tool}\` hit the same error ${top.count} times: “${top.pattern}”.`,
347 })
348 }
349
350 if (t.timedCalls >= 2 && avg(t.runMs, t.timedCalls) >= slowMs) {
351 hints.push({
352 kind: 'slow',
353 weight: avg(t.runMs, t.timedCalls) >= slowMs * 3 ? 2 : 1,
354 tool,
355 text: `\`${tool}\` takes ${fmtMs(avg(t.runMs, t.timedCalls))} on average (slowest ${fmtMs(t.maxRunMs)}). Check whether the Obsidian CLI starts cold on each call, or cache in the server.`,
356 })
357 }
358
359 if (t.denied > 0) {
360 hints.push({
361 kind: 'denied',
362 weight: 1,
363 tool,
364 text: `\`${tool}\` was denied ${t.denied} time${t.denied === 1 ? '' : 's'}. If it is safe, an allow rule saves the prompts; if not, a deny rule saves the attempts.`,
365 })
366 }
367 }
368
369 const repeated = Object.values(repeats)
370 .filter(r => r.count >= 2)
371 .sort((a, b) => b.wastedTokens - a.wastedTokens || b.count - a.count)
372 for (const r of repeated.slice(0, 5)) {
373 hints.push({
374 kind: 'repeat',
375 weight: r.wastedTokens >= heavyTokens * 2 || r.count >= 4 ? 3 : 2,
376 tool: r.tool,
377 text: `\`${r.tool}\` ran ${r.count}× with the same arguments ${cut(r.args, 80)}, re-reading ≈${fmtTok(r.wastedTokens)} tok. The answer was already in context; a note in CLAUDE.md or a cache in the server would save it.`,
378 })
379 }
380 if (repeated.length > 5)
381 hints.push({ kind: 'repeat', weight: 1, text: `${repeated.length - 5} more argument sets were repeated; see the copied report for all of them.` })
382
383 const loadedUnused = schemas.filter(s => s.isLoaded && !(tools[s.tool]?.calls ?? 0))
384 if (loadedUnused.length > 0) {
385 const tokens = loadedUnused.reduce((n, s) => n + s.tokens, 0)
386 hints.push({
387 kind: 'schema',
388 weight: tokens >= 2000 ? 2 : 1,
389 text: `${loadedUnused.length} tool schema${loadedUnused.length === 1 ? ' is' : 's are'} in context but not called this session, costing ≈${fmtTok(tokens)} tok on every request: ${loadedUnused
390 .sort((a, b) => b.tokens - a.tokens)
391 .slice(0, 6)
392 .map(s => `\`${s.tool}\` (${fmtTok(s.tokens)})`)
393 .join(', ')}${loadedUnused.length > 6 ? ', …' : ''}.`,
394 })
395 }
396
397 const allTime = mergeHistory(history, tools, [], 0).tools
398 const sessions = history.sessions + (Object.values(tools).some(t => t.calls > 0) ? 1 : 0)
399 const neverCalled = schemas.filter(s => !(allTime[s.tool]?.calls ?? 0))
400 if (neverCalled.length > 0 && sessions >= 3) {
401 const tokens = neverCalled.reduce((n, s) => n + s.tokens, 0)
402 hints.push({
403 kind: 'schema',
404 weight: neverCalled.length >= 5 ? 2 : 1,
405 text: `${neverCalled.length} tool${neverCalled.length === 1 ? ' has' : 's have'} never been called in ${sessions} sessions (schemas ≈${fmtTok(tokens)} tok): ${neverCalled
406 .map(s => `\`${s.tool}\``)
407 .slice(0, 10)
408 .join(', ')}${neverCalled.length > 10 ? ', …' : ''}. Candidates to merge, hide, or describe better.`,
409 })
410 }
411
412 return hints.sort((a, b) => b.weight - a.weight)
413}
414
415// ---------------------------------------------------------------- markdown
416
417const esc = (text: string): string => text.replace(/\|/g, '\\|').replace(/\r?\n/g, ' ')
418
419export function toolsTable(tools: Readonly<Record<string, ToolTotals>>, sort: StatsSort, isCompact: boolean, now: number): string {
420 const rows = sortedTools(tools, sort).filter(([, t]) => t.calls > 0)
421 if (rows.length === 0) return '_No obsidian-cli-mcp calls yet this session._'
422 if (isCompact) {
423 return [
424 '| Tool | Calls | Err | ≈Tok read | Avg time |',
425 '| --- | ---: | ---: | ---: | ---: |',
426 ...rows.map(([n, t]) => `| \`${esc(n)}\` | ${t.calls} | ${t.errors || ''} | ${fmtTok(t.outTokens)} | ${fmtMs(avg(t.runMs, t.timedCalls))} |`),
427 ].join('\n')
428 }
429 return [
430 '| Tool | Calls | Err | ≈Tok sent | ≈Tok read | Avg read | Largest | Avg time | Slowest | Last |',
431 '| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |',
432 ...rows.map(
433 ([n, t]) =>
434 `| \`${esc(n)}\` | ${t.calls}${t.subagentCalls ? ` (${t.subagentCalls} sub)` : ''} | ${t.errors || ''}${t.denied ? ` +${t.denied} denied` : ''} | ${fmtTok(t.inTokens)} | ${fmtTok(t.outTokens)} | ${fmtTok(avg(t.outTokens, t.calls))} | ${fmtTok(t.maxOutTokens)} | ${fmtMs(avg(t.runMs, t.timedCalls))} | ${fmtMs(t.maxRunMs)} | ${fmtAgo(t.lastAt, now)} |`,
435 ),
436 ].join('\n')
437}
438
439export function errorsMarkdown(errors: readonly ErrorGroup[], recent: readonly CallRecord[], now: number): string {
440 if (errors.length === 0 && !recent.some(c => c.isDenied)) return '_No errors this session._'
441 const groups = [...errors].sort((a, b) => b.count - a.count || b.lastAt - a.lastAt)
442 const denied = recent.filter(c => c.isDenied).slice(-5).reverse()
443 const failed = recent.filter(c => c.isError).slice(-5).reverse()
444 return [
445 groups.length ? '**By pattern**' : '',
446 groups.length ? '| Tool | × | Error (masked) | Last |\n| --- | ---: | --- | ---: |' : '',
447 ...groups.map(g => `| \`${esc(g.tool)}\` | ${g.count} | ${esc(g.pattern)} | ${fmtAgo(g.lastAt, now)} |`),
448 failed.length ? '\n**Latest failures**' : '',
449 ...failed.map(c => `- \`${c.tool}\` ${esc(cut(c.args, 80))}\n → ${esc(c.error ?? 'error')}`),
450 denied.length ? '\n**Latest denials**' : '',
451 ...denied.map(c => `- \`${c.tool}\` ${esc(cut(c.args, 80))}: ${esc(c.error ?? 'denied')}`),
452 ]
453 .filter(Boolean)
454 .join('\n')
455}
456
457export function hintsMarkdown(hints: readonly Hint[], schemasAt: number): string {
458 const lines: string[] = []
459 if (hints.length === 0) lines.push('_Nothing to tune yet: hints appear as calls come in._')
460 for (const kind of ['heavy', 'repeat', 'errors', 'schema', 'slow', 'denied'] as const) {
461 const of = hints.filter(h => h.kind === kind)
462 if (of.length === 0) continue
463 lines.push(`**${HINT_TITLES[kind]}**`)
464 for (const h of of) lines.push(`- ${'●'.repeat(h.weight)}${'○'.repeat(3 - h.weight)} ${h.text}`)
465 lines.push('')
466 }
467 if (!schemasAt) lines.push('_Schema costs not read yet: press Refresh._')
468 return lines.join('\n').trim()
469}
470
471export function historyMarkdown(history: History, tools: Readonly<Record<string, ToolTotals>>, schemas: readonly SchemaCost[], firstAt: number, now: number): string {
472 const all = mergeHistory(history, tools, [], firstAt || now)
473 const rows = sortedTools(all.tools, 'calls').filter(([, t]) => t.calls > 0)
474 const total = sumTotals(all.tools)
475 const lines = [
476 all.sessions
477 ? `${all.sessions} session${all.sessions === 1 ? '' : 's'} since ${fmtDate(all.since)} · ${total.calls} calls · ≈${fmtTok(total.outTokens)} tok read · ${total.errors} errors (${fmtPct(total.errors, total.calls)})`
478 : '_No history yet: it builds up as sessions call the server._',
479 ]
480 if (rows.length) {
481 lines.push(
482 '',
483 '| Tool | Calls | Share | Err % | Avg read | Avg time | Last used |',
484 '| --- | ---: | ---: | ---: | ---: | ---: | ---: |',
485 ...rows.map(
486 ([n, t]) =>
487 `| \`${esc(n)}\` | ${t.calls} | ${fmtPct(t.calls, total.calls)} | ${fmtPct(t.errors, t.calls)} | ${fmtTok(avg(t.outTokens, t.calls))} | ${fmtMs(avg(t.runMs, t.timedCalls))} | ${fmtDate(t.lastAt)} |`,
488 ),
489 )
490 }
491 const never = schemas.filter(s => !(all.tools[s.tool]?.calls ?? 0)).map(s => `\`${s.tool}\``)
492 if (never.length) lines.push('', `**Never called:** ${never.join(', ')}`)
493 return lines.join('\n')
494}
495
496export type ReportInput = HintInput & { recent: readonly CallRecord[]; schemasAt: number; firstAt: number; now: number; server: string }
497
498/** The whole picture as Markdown, for the clipboard: paste it into a session to have Claude act on it. */
499export function reportMarkdown(r: ReportInput): string {
500 const hints = hintsOf(r)
501 const schemaTokens = r.schemas.reduce((n, s) => n + s.tokens, 0)
502 const loaded = r.schemas.filter(s => s.isLoaded)
503 return [
504 `# ${r.server} tool usage report`,
505 '',
506 summaryLine(r.tools),
507 r.schemas.length
508 ? `Schemas: ${r.schemas.length} tools, ≈${fmtTok(schemaTokens)} tok in all; ${loaded.length} loaded now (≈${fmtTok(loaded.reduce((n, s) => n + s.tokens, 0))} tok).`
509 : '',
510 '',
511 '## This session',
512 '',
513 toolsTable(r.tools, 'tokens', false, r.now),
514 '',
515 '## Optimisation hints',
516 '',
517 hintsMarkdown(hints, r.schemasAt),
518 '',
519 '## Errors',
520 '',
521 errorsMarkdown(r.errors, r.recent, r.now),
522 '',
523 '## All sessions',
524 '',
525 historyMarkdown(r.history, r.tools, r.schemas, r.firstAt, r.now),
526 '',
527 '_Token counts are estimates (about four characters a token) of the arguments sent and the results read._',
528 ]
529 .filter(line => line !== undefined)
530 .join('\n')
531}
532
533// ---------------------------------------------------------------- desktop chart
534
535const metricOf = (t: ToolTotals, sort: StatsSort): number =>
536 sort === 'calls' ? t.calls : sort === 'tokens' ? t.outTokens + t.inTokens : sort === 'errors' ? t.errors + t.denied : avg(t.runMs, t.timedCalls)
537
538const labelOf = (t: ToolTotals, sort: StatsSort): string =>
539 sort === 'calls' ? String(t.calls) : sort === 'tokens' ? `≈${fmtTok(t.outTokens + t.inTokens)}` : sort === 'errors' ? String(t.errors + t.denied) : fmtMs(avg(t.runMs, t.timedCalls))
540
541export const CHART_BAR_H = 18
542
543const xmlEsc = (s: string): string => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
544
545/** A static bar chart of the top tools by the sort in force; no animation, so it reads the same under reduced motion. */
546export function chartSvg(tools: Readonly<Record<string, ToolTotals>>, sort: StatsSort, width: number, rows = 8): { svg: string; height: number; alt: string } {
547 const top = sortedTools(tools, sort)
548 .filter(([, t]) => t.calls > 0)
549 .slice(0, rows)
550 const max = Math.max(1, ...top.map(([, t]) => metricOf(t, sort)))
551 const labelW = 150
552 const valueW = 70
553 const barMax = Math.max(40, width - labelW - valueW)
554 const height = Math.max(CHART_BAR_H, top.length * CHART_BAR_H) + 4
555 const bars = top
556 .map(([name, t], i) => {
557 const y = 2 + i * CHART_BAR_H
558 const w = Math.max(2, Math.round((metricOf(t, sort) / max) * barMax))
559 const errW = sort === 'calls' && t.calls ? Math.round((t.errors / t.calls) * w) : 0
560 return [
561 `<text x="${labelW - 8}" y="${y + 13}" text-anchor="end" font-family="ui-monospace,Menlo,Consolas,monospace" font-size="12" fill="#8a8a8a">${xmlEsc(cut(name, 20))}</text>`,
562 `<rect x="${labelW}" y="${y + 3}" width="${w}" height="${CHART_BAR_H - 6}" rx="3" fill="#8b5cf6"/>`,
563 errW ? `<rect x="${labelW + w - errW}" y="${y + 3}" width="${errW}" height="${CHART_BAR_H - 6}" rx="3" fill="#e5484d"/>` : '',
564 `<text x="${labelW + w + 6}" y="${y + 13}" font-family="ui-sans-serif,system-ui,sans-serif" font-size="12" fill="#8a8a8a">${xmlEsc(labelOf(t, sort))}</text>`,
565 ].join('')
566 })
567 .join('')
568 const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${bars}</svg>`
569 const alt = top.length
570 ? `Top obsidian-cli-mcp tools by ${sort}: ${top.map(([n, t]) => `${n} ${labelOf(t, sort)}`).join(', ')}`
571 : 'No obsidian-cli-mcp calls yet'
572 return { svg, height, alt }
573}
574types/index.d.ts 107 lines1/** One finished call to a tracked tool. */
2export type CallRecord = {
3 id: string
4 /** The tool's short name, without the `mcp__<server>__` prefix. */
5 tool: string
6 at: number
7 /** Wall time around the call, permission prompt included. */
8 wallMs: number
9 /** Execution time as the engine measured it, permission prompt excluded; absent when it did not say. */
10 runMs?: number
11 /** Estimated tokens of the arguments the model wrote. */
12 inTokens: number
13 /** Estimated tokens of the result the model read. */
14 outTokens: number
15 isError: boolean
16 isDenied: boolean
17 /** True when a subagent made the call. */
18 isSubagent: boolean
19 /** The arguments, cut short, for the recent list and the repeat hints. */
20 args: string
21 /** The error's first line, cut short. */
22 error?: string
23}
24
25/** Totals for one tool. */
26export type ToolTotals = {
27 calls: number
28 errors: number
29 denied: number
30 inTokens: number
31 outTokens: number
32 maxOutTokens: number
33 /** Summed execution time over the calls that reported one (`timedCalls`). */
34 runMs: number
35 maxRunMs: number
36 timedCalls: number
37 subagentCalls: number
38 lastAt: number
39}
40
41/** Errors of one tool that read alike, once numbers, paths and quoted values are masked. */
42export type ErrorGroup = {
43 tool: string
44 /** The masked message. */
45 pattern: string
46 /** The latest message as it was. */
47 sample: string
48 count: number
49 lastAt: number
50}
51
52/** Calls of one tool with the very same arguments. */
53export type RepeatGroup = {
54 tool: string
55 args: string
56 count: number
57 /** Result tokens of every call after the first: what a cache would have saved. */
58 wastedTokens: number
59}
60
61/** One tool's schema as the context carries it. */
62export type SchemaCost = {
63 tool: string
64 tokens: number
65 /** False while tool schemas load on demand and this one has not been searched for. */
66 isLoaded: boolean
67}
68
69/** What `$.store` keeps across sessions. */
70export type History = {
71 /** Sessions that called at least one tracked tool. */
72 sessions: number
73 since: number
74 tools: Record<string, ToolTotals>
75 errors: ErrorGroup[]
76}
77
78export type StatsTab = 'tools' | 'errors' | 'hints' | 'history'
79export type StatsSort = 'calls' | 'tokens' | 'errors' | 'time'
80
81export type StatsView = {
82 tab: StatsTab
83 sort: StatsSort
84 /** Set by the copy button; drawn as a short note until the next press. */
85 note: string
86}
87
88declare module 'claude-code' {
89 interface PluginState {
90 'obsidian-cli-mcp-tool-stats': {
91 tools: Record<string, ToolTotals>
92 recent: CallRecord[]
93 errors: ErrorGroup[]
94 /** Keyed by tool and arguments. */
95 repeats: Record<string, RepeatGroup>
96 /** Execution times the engine reported before the call's own hook finished, by tool_use_id. */
97 pendingRunMs: Record<string, number>
98 schemas: SchemaCost[]
99 schemasAt: number
100 /** Every session before this one, as read from the store when this one started. */
101 baseline: History
102 isBaselineLoaded: boolean
103 view: StatsView
104 }
105 }
106}
107