SLOPSHOPPER

obsidian-cli-mcp-tool-stats

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…

newpaneguardcommandtimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · obsidian-cli-mcp-tool-stats
│ ┃ obsidian-cli-mcp tool stats ✕ › fix the failing auth test and add an audit log call │ ┃ No calls yet this session. │ ┃ ⏺ Read(src/auth.ts) │ ┃ [ Tools ] [ Errors ] [ Hints ] [ History ] ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ t: ▾ Tokens c: Calls e: Errors m: Time r: Re ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ No obsidian-cli-mcp calls yet this session. ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /cli-mcp-tool-stats │ ⎿ obsidian-cli-mcp-tool-stats: obsidian-cli-mcp tool stats opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · obsidian-cli-mcp tool stats
No calls yet this session. [ Tools ] [ Errors ] [ Hints ] [ History ] t: ▾ Tokens c: Calls e: Errors m: Time r: Refresh y: Copy re No obsidian-cli-mcp calls yet this session.
README

obsidian-cli-mcp-tool-stats

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.

Install

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.

Commands

CommandDoes
/cli-mcp-tool-statsOpens the stats pane, or closes it when it is open
/cli-mcp-tool-stats reportWrites the full report into the conversation as Markdown, so Claude can read it and act on it
/cli-mcp-tool-stats resetStarts this session's figures over; what was counted so far stays in the history
/cli-mcp-tool-stats forgetClears the history of earlier sessions

The pane

Press the tab buttons, or their hotkeys while the pane has focus:

  • 1 Tools: one row per tool, with calls (and how many came from subagents), errors and denials, ≈tokens sent and read, the average and largest result, average and slowest time, and when it was last used. Sort it by tokens, calls, errors or time. On the desktop a bar chart of the top tools sits above the table, its error share in red.
  • 2 Errors: errors grouped by pattern, with paths, numbers and quoted values masked so the same fault on different notes counts as one, followed by the latest failures and denials with their arguments.
  • 3 Hints: what to tune, the most worth acting on first (●●●).
  • 4 History: every session so far, per tool, plus the tools never called.

r re-reads the schema costs. y copies the whole report as Markdown.

Hints

HintWhen it shows
Heavy resultsA 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 callsThe same tool called with the same arguments more than once, and the tokens the repeats re-read.
Error patternsA tool fails 20% of the time or more (three calls at least), or hits the same error twice.
Unused schemasTool schemas loaded into context but not called this session, with their token cost per request. After three sessions, tools never called at all.
Slow callsA tool averages longer than the slow threshold.
Permission denialsA tool was denied, so an allow or deny rule would save the prompt.

Settings

SettingDefaultMeaning
serverobsidian-cli-mcpThe server tracked, as /mcp names it
heavyResultTokens2000Average result size, in tokens, above which a tool counts as heavy
slowCallMs3000Average time, in ms, above which a tool counts as slow
keepHistorytrueKeep per-tool totals and error patterns across sessions

How it works

  • A 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.
  • Timing comes from the engine's PostToolUse duration_ms, which leaves out time spent waiting on a permission prompt. When that is missing the mod falls back to wall time.
  • Schema costs come from the session's context breakdown ($.session.usage({ breakdown: 'summary' })), read when the pane opens and on r.
  • Session figures live in $.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.
  • The pane draws Markdown tables on every surface. The desktop adds a static SVG chart with an alt text and no animation.

Limits

  • Token counts are estimates of about four characters a token, from the argument JSON and the result text. They show which tools cost most, not exact billing. A result stays in context until compaction, so a heavy result costs more than its first read.
  • Repeat detection compares the arguments exactly. Two reads of the same note written differently count as different calls.
  • History keeps per-tool totals and up to 60 error patterns, not individual calls. The pane keeps the last 200 calls of a session.
  • Schema costs list only tools in the current context breakdown. While tool schemas load on demand, a schema that hasn't been searched for costs nothing yet and is not flagged as loaded but unused.
  • Built on Claude Code's function-hook plugin API (early access), tested against Claude Code 2.1.295.
Source 3 files
hooks/register.tsx 345 lines
1import { 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}
345
hooks/stats.ts 574 lines
1import 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
574
types/index.d.ts 107 lines
1/** 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