@saifymatteo/pi-tool-usage-log 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,32 @@
1
- # Temporary Holding Version
1
+ # pi-tool-usage-log
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Passively record direct and nested tool executions and show aggregate usage, errors, duration, selected model and thinking level.
4
+
5
+ ## Running locally
6
+
7
+ From the monorepo root:
8
+
9
+ ```sh
10
+ pi -e ./packages/pi-tool-usage-log
11
+ ```
12
+
13
+ **Warning:** this extension uses your existing Pi resources and settings unless you load it in an isolated home (see the root README). Do not load two copies of the same extension (for example an older standalone file alongside this package): commands and hooks can duplicate. The package never installs or relocates personal settings.
14
+
15
+ ## Commands and shortcuts
16
+
17
+ - `/tool-usage` — opens the all-project report (no arguments).
18
+ - Up/Down, PageUp/PageDown, Home/End scroll; Escape/Ctrl+C/Enter close.
19
+
20
+ ## Settings and storage
21
+
22
+ Appends JSONL records to `<agent-dir>/tool-usage.log`; `<agent-dir>` is `PI_CODING_AGENT_DIR` or `~/.pi/agent`. No configuration file. The copied `skill-usage-log.ts` is a dialog sidecar only: only `src/index.ts` is a Pi manifest entry, so loading this package does not independently register skill logging.
23
+
24
+ ## Privacy and operations
25
+
26
+ Log stores timestamps, session IDs, working directories, full tool/call/parent-call names, selected model/thinking level, status and durations, never tool arguments/results. No network requests. There is no rotation/deletion UI. Interrupted pending calls are flushed on orderly shutdown/session change; a hard crash can lose them. MCP tools aggregate by server in reports but keep full names on disk. Success means the tool returned, not that a background job eventually succeeded.
27
+
28
+ ## Compatibility
29
+
30
+ Node.js >=24 for development checks; run extensions through Pi's TypeScript loader. Host imports are wildcard peers, not bundled runtime copies. Reports require interactive terminal mode; execution tracking needs Pi tool_execution_start/end hooks, including nested-event metadata for nested counts. Older hosts may omit these. Duration includes hooks/nested work and is not a pure tool benchmark.
31
+
32
+ See [NOTICE.md](NOTICE.md) for provenance. Tests use the monorepo's TypeScript dev dependency and VM loader; they do not contact services or write to user-home resources.
package/package.json CHANGED
@@ -1,6 +1,36 @@
1
1
  {
2
2
  "name": "@saifymatteo/pi-tool-usage-log",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "license": "AGPL-3.0-only",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package"
8
+ ],
9
+ "files": [
10
+ "src",
11
+ "README.md",
12
+ "NOTICE.md",
13
+ "LICENSE"
14
+ ],
15
+ "pi": {
16
+ "extensions": [
17
+ "./src/index.ts"
18
+ ]
19
+ },
20
+ "peerDependencies": {
21
+ "@earendil-works/pi-coding-agent": "*",
22
+ "@earendil-works/pi-tui": "*"
23
+ },
24
+ "engines": {
25
+ "node": ">=24"
26
+ },
27
+ "description": "Passive log of tool executions (names and status, never contents).",
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/saifymatteo/pi-extensions.git",
34
+ "directory": "packages/pi-tool-usage-log"
35
+ }
36
+ }
package/src/index.ts ADDED
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Passive, forward-only tool usage log. Includes direct and nested executions,
3
+ * but never arguments/results. Success means the tool returned, not that a
4
+ * background command or delegated agent eventually succeeded.
5
+ */
6
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
7
+ import { appendFileSync, mkdirSync } from 'node:fs'
8
+ import { readFile } from 'node:fs/promises'
9
+ import { homedir } from 'node:os'
10
+ import { dirname, join } from 'node:path'
11
+ import { SkillUsageDialog } from './skill-usage-log.ts'
12
+ import { DIALOG_OVERLAY } from './lib/dialog.ts'
13
+ import { agentMetadata, localTimestamp, logField, markdownCell, usageTimeFormat } from './lib/usage-log.ts'
14
+
15
+ export interface ToolUsageRecord {
16
+ version: 1
17
+ timestamp: string
18
+ session: string
19
+ cwd: string
20
+ tool: string
21
+ callId: string
22
+ parentCallId?: string
23
+ model: string
24
+ thinking: string
25
+ status: 'success' | 'error' | 'interrupted'
26
+ durationMs: number
27
+ }
28
+
29
+ export interface ToolUsageRow {
30
+ tool: string
31
+ calls: number
32
+ sessions: number
33
+ errors: number
34
+ nested: number
35
+ durationMs: number
36
+ /** null = installed but never called. */
37
+ lastUsed: string | null
38
+ }
39
+
40
+ export interface ToolUsageBreakdown extends ToolUsageRow {
41
+ model: string
42
+ thinking: string
43
+ }
44
+
45
+ /** MCP tools (`mcp__<server>__<tool>`) report as one row per server, `mcp__<server>__*`; the log keeps full names. */
46
+ const toolGroup = (tool: string) => tool.match(/^(mcp__.+?__)./)?.[1].concat('*') ?? tool
47
+
48
+ /** Malformed/partial rows are ignored, not allowed to spoil the whole report.
49
+ * When supplied, `installed` filters removed tools and adds unseen tools as zero rows. */
50
+ export function summarizeToolUsage(log: string, installed?: string[]): {
51
+ tools: ToolUsageRow[]; breakdown: ToolUsageBreakdown[]; skipped: number
52
+ } {
53
+ const tools = new Map<string, { row: ToolUsageRow; sessions: Set<string> }>()
54
+ const groups = new Map<string, { row: ToolUsageBreakdown; sessions: Set<string> }>()
55
+ let skipped = 0
56
+ for (const line of log.split(/\r?\n/)) {
57
+ if (!line.trim()) continue
58
+ let record: ToolUsageRecord
59
+ try {
60
+ const value = JSON.parse(line)
61
+ if (!value || value.version !== 1 ||
62
+ !['timestamp', 'session', 'cwd', 'tool', 'callId', 'model', 'thinking'].every(key => typeof value[key] === 'string') ||
63
+ !value.session || !value.tool || !value.callId ||
64
+ !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{2}:\d{2}$/.test(value.timestamp) ||
65
+ !Number.isFinite(Date.parse(value.timestamp)) ||
66
+ (value.parentCallId !== undefined && typeof value.parentCallId !== 'string') ||
67
+ !['success', 'error', 'interrupted'].includes(value.status) ||
68
+ typeof value.durationMs !== 'number' || !Number.isFinite(value.durationMs) || value.durationMs < 0) {
69
+ skipped++
70
+ continue
71
+ }
72
+ record = value
73
+ } catch {
74
+ skipped++
75
+ continue
76
+ }
77
+ const lastUsed = new Date(record.timestamp).toISOString()
78
+ const name = toolGroup(record.tool)
79
+ let entry = tools.get(name)
80
+ if (!entry) {
81
+ entry = { row: { tool: name, calls: 0, sessions: 0, errors: 0, nested: 0, durationMs: 0, lastUsed }, sessions: new Set() }
82
+ tools.set(name, entry)
83
+ }
84
+ const key = JSON.stringify([name, record.model, record.thinking])
85
+ let group = groups.get(key)
86
+ if (!group) {
87
+ group = { row: { ...entry.row, calls: 0, sessions: 0, errors: 0, nested: 0, durationMs: 0, lastUsed, model: record.model, thinking: record.thinking }, sessions: new Set() }
88
+ groups.set(key, group)
89
+ }
90
+ for (const aggregate of [entry, group]) {
91
+ aggregate.row.calls++
92
+ aggregate.sessions.add(record.session)
93
+ aggregate.row.sessions = aggregate.sessions.size
94
+ if (record.status !== 'success') aggregate.row.errors++
95
+ if (record.parentCallId) aggregate.row.nested++
96
+ aggregate.row.durationMs += record.durationMs
97
+ if (lastUsed > aggregate.row.lastUsed!) aggregate.row.lastUsed = lastUsed
98
+ }
99
+ }
100
+ if (installed !== undefined) {
101
+ const available = new Set(installed.map(toolGroup))
102
+ for (const tool of tools.keys()) if (!available.has(tool)) tools.delete(tool)
103
+ for (const [key, group] of groups) if (!available.has(group.row.tool)) groups.delete(key)
104
+ for (const tool of available) {
105
+ if (!tools.has(tool)) tools.set(tool, { row: { tool, calls: 0, sessions: 0, errors: 0, nested: 0, durationMs: 0, lastUsed: null }, sessions: new Set() })
106
+ }
107
+ }
108
+ const sort = (a: ToolUsageRow, b: ToolUsageRow) => b.calls - a.calls || a.tool.localeCompare(b.tool)
109
+ return {
110
+ tools: [...tools.values()].map(({ row }) => row).sort(sort),
111
+ breakdown: [...groups.values()].map(({ row }) => row).sort((a, b) => sort(a, b) || a.model.localeCompare(b.model) || a.thinking.localeCompare(b.thinking)),
112
+ skipped,
113
+ }
114
+ }
115
+
116
+ export default function register(pi: ExtensionAPI): void {
117
+ const agentDir = (process.env.PI_CODING_AGENT_DIR || join(homedir(), '.pi', 'agent')).replace(/^~(?=$|[\\/])/, homedir())
118
+ const logPath = join(agentDir, 'tool-usage.log')
119
+ const pending = new Map<string, { record: Omit<ToolUsageRecord, 'status' | 'durationMs'>; start: number }>()
120
+ let session: string | undefined
121
+ let logError: string | undefined
122
+ let warned = false
123
+
124
+ function forSession(ctx: ExtensionContext): void {
125
+ const sid = ctx.sessionManager.getSessionId()
126
+ if (session === sid) return
127
+ flushPending()
128
+ warn(ctx)
129
+ session = sid
130
+ logError = undefined
131
+ warned = false
132
+ }
133
+
134
+ function append(record: ToolUsageRecord): void {
135
+ try {
136
+ mkdirSync(dirname(logPath), { recursive: true })
137
+ appendFileSync(logPath, JSON.stringify(record) + '\n')
138
+ } catch (error) {
139
+ logError = error instanceof Error ? error.message : String(error)
140
+ }
141
+ }
142
+
143
+ function flushPending(): void {
144
+ for (const { record, start } of pending.values()) {
145
+ append({ ...record, status: 'interrupted', durationMs: Math.max(0, Math.round(performance.now() - start)) })
146
+ }
147
+ pending.clear()
148
+ }
149
+
150
+ function warn(ctx: ExtensionContext): void {
151
+ if (logError && !warned && ctx.hasUI) {
152
+ warned = true
153
+ try { ctx.ui.notify(`Tool usage could not be logged to ${logPath}: ${logError}`, 'warning') }
154
+ catch { /* A closed UI must not turn a logging failure into a tool failure. */ }
155
+ }
156
+ }
157
+
158
+ pi.on('session_start', (_event, ctx) => { forSession(ctx) })
159
+ pi.on('tool_execution_start', (event, ctx) => {
160
+ // Observation must never block a real tool, even if metadata is unavailable.
161
+ try {
162
+ forSession(ctx)
163
+ pending.set(event.toolCallId, {
164
+ start: performance.now(),
165
+ record: {
166
+ version: 1, timestamp: localTimestamp(), session: logField(session!),
167
+ cwd: logField(ctx.cwd), tool: logField(event.toolName), callId: logField(event.toolCallId),
168
+ ...(event.parentToolCallId ? { parentCallId: logField(event.parentToolCallId) } : {}),
169
+ ...agentMetadata(pi, ctx),
170
+ },
171
+ })
172
+ } catch (error) {
173
+ logError = error instanceof Error ? error.message : String(error)
174
+ }
175
+ })
176
+ pi.on('tool_execution_end', (event, ctx) => {
177
+ const call = pending.get(event.toolCallId)
178
+ if (!call) return // Started before reload, or already recorded.
179
+ pending.delete(event.toolCallId)
180
+ append({ ...call.record, status: event.isError ? 'error' : 'success', durationMs: Math.max(0, Math.round(performance.now() - call.start)) })
181
+ warn(ctx)
182
+ })
183
+ pi.on('turn_end', (_event, ctx) => { warn(ctx) })
184
+ pi.on('session_shutdown', (_event, ctx) => {
185
+ flushPending()
186
+ warn(ctx)
187
+ })
188
+
189
+ pi.registerCommand('tool-usage', {
190
+ description: 'List historical tool calls, sessions, errors, model and thinking level',
191
+ handler: async (args, ctx) => {
192
+ if (ctx.mode !== 'tui') {
193
+ ctx.ui.notify('Tool usage requires interactive terminal mode; no report was added to the transcript.', 'info')
194
+ return
195
+ }
196
+ if (args.trim()) {
197
+ ctx.ui.notify('Usage: /tool-usage (all logged sessions and projects)', 'info')
198
+ return
199
+ }
200
+ try {
201
+ let log = ''
202
+ try { log = await readFile(logPath, 'utf8') }
203
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
204
+ const { tools, breakdown, skipped } = summarizeToolUsage(log, pi.getAllTools().map((tool) => tool.name))
205
+ const time = usageTimeFormat()
206
+ const lines = ['Tool usage — all logged sessions and projects', '',
207
+ 'Dates follow your locale. Different historical daylight-saving offsets are shown beside the date.',
208
+ 'Tracking starts when installed; no historical import. Every registered tool is listed; 0 / never = not called since then.',
209
+ 'MCP tools are grouped per server as mcp__<server>__*.',
210
+ 'Direct and nested calls are counted separately. Errors include interrupted calls; unfinished calls appear on orderly shutdown.',
211
+ 'Success means the tool returned, not that a background job succeeded. Duration includes hooks and nested work.', '',
212
+ `| Tool | Calls | Sessions | Errors | Nested | Avg ms | Last used (${markdownCell(time.label)}) |`,
213
+ '| --- | ---: | ---: | ---: | ---: | ---: | --- |']
214
+ for (const row of tools) {
215
+ lines.push(`| ${markdownCell(row.tool)} | ${row.calls} | ${row.sessions} | ${row.errors} | ${row.nested} | ${row.calls ? Math.round(row.durationMs / row.calls) : '—'} | ${row.lastUsed ? markdownCell(time.format(new Date(row.lastUsed))) : 'never'} |`)
216
+ }
217
+ if (!tools.some((row) => row.calls)) lines.push('', 'No tool usage recorded yet.')
218
+ if (breakdown.length) {
219
+ lines.push('', 'Agent model / thinking at call start (not a tool’s internal service model).', '',
220
+ '| Tool | Model | Thinking | Calls | Sessions | Errors |',
221
+ '| --- | --- | --- | ---: | ---: | ---: |')
222
+ for (const row of breakdown) lines.push(`| ${markdownCell(row.tool)} | ${markdownCell(row.model)} | ${markdownCell(row.thinking)} | ${row.calls} | ${row.sessions} | ${row.errors} |`)
223
+ }
224
+ if (skipped) lines.push('', `Skipped ${skipped} malformed log row(s).`)
225
+ lines.push('', `Log: ${markdownCell(logPath.replaceAll('\\', '/'))}`)
226
+ await ctx.ui.custom<void>((tui, theme, _keybindings, done) =>
227
+ new SkillUsageDialog(tui, theme, lines.join('\n'), done, 'Tool usage'), DIALOG_OVERLAY)
228
+ } catch (error) {
229
+ ctx.ui.notify(`Could not show tool usage: ${error instanceof Error ? error.message : String(error)}`, 'error')
230
+ }
231
+ },
232
+ })
233
+ }
@@ -0,0 +1,31 @@
1
+ // One size for every overlay dialog in this package. Change DIALOG_HEIGHT here and every dialog follows:
2
+ // the overlay is capped at that share of the terminal and each dialog pads its list to fill it.
3
+
4
+ /** Share of the terminal height every dialog fills. */
5
+ export const DIALOG_HEIGHT = 0.8
6
+
7
+ /** `ctx.ui.custom(factory, DIALOG_OVERLAY)`: centered, 92% wide, DIALOG_HEIGHT tall. */
8
+ export const DIALOG_OVERLAY = {
9
+ overlay: true,
10
+ overlayOptions: { anchor: 'center', width: '92%', maxHeight: `${DIALOG_HEIGHT * 100}%`, margin: 1 },
11
+ } as const
12
+
13
+ /**
14
+ * Scrolling rows that fit once `chrome` fixed lines (borders, search, header, footer) are drawn.
15
+ * Same floor as the overlay's own percentage, so dialog and overlay agree; read it in render() so a
16
+ * resize takes effect on the next frame.
17
+ */
18
+ export const dialogRows = (tui: { terminal?: { rows: number } }, chrome: number, min = 1): number =>
19
+ Math.max(min, Math.floor((tui.terminal?.rows ?? 24) * DIALOG_HEIGHT) - chrome)
20
+
21
+ /**
22
+ * Mouse wheel → ↑/↓ key presses, so a dialog reuses its own keyboard handling (clamping, render).
23
+ * Use as `handleMouse(e) { return wheelAsArrows(e, (k) => this.handleInput(k)) }`. Fullscreen mode only:
24
+ * regular mode leaves the mouse to the terminal. Handling the wheel also stops it scrolling the transcript behind.
25
+ */
26
+ export function wheelAsArrows(event: { type: string; wheelDelta?: number }, handleInput: (data: string) => void): { handled: true } | undefined {
27
+ if (event.type !== 'wheel' || !event.wheelDelta) return undefined
28
+ const key = event.wheelDelta < 0 ? '\x1b[A' : '\x1b[B'
29
+ for (let i = 0; i < Math.min(Math.abs(event.wheelDelta), 20); i++) handleInput(key)
30
+ return { handled: true }
31
+ }
@@ -0,0 +1,62 @@
1
+ /** Shared metadata and local-time formatting for the two passive usage logs. */
2
+ export function userTimeZone(): string {
3
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
4
+ }
5
+
6
+ /** An ISO timestamp with an explicit offset; DST and fractional-hour zones work. */
7
+ export function localTimestamp(date = new Date(), timeZone = userTimeZone()): string {
8
+ const parts = Object.fromEntries(new Intl.DateTimeFormat('en-GB', {
9
+ timeZone, year: 'numeric', month: '2-digit', day: '2-digit',
10
+ hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
11
+ timeZoneName: 'longOffset',
12
+ }).formatToParts(date).map(({ type, value }) => [type, value]))
13
+ const offset = parts.timeZoneName.replace('GMT', '') || '+00:00'
14
+ return `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}.${String(date.getUTCMilliseconds()).padStart(3, '0')}${offset}`
15
+ }
16
+
17
+ /** Human-readable report dates; keep machine-readable log timestamps separate. */
18
+ export function usageTimeFormat(timeZone = userTimeZone(), locale?: string | string[], now = new Date()) {
19
+ const formatter = new Intl.DateTimeFormat(locale, { timeZone, dateStyle: 'medium', timeStyle: 'short' })
20
+ const offset = localTimestamp(now, timeZone).slice(-6)
21
+ const location = timeZone.split('/').pop()!.replaceAll('_', ' ')
22
+ return {
23
+ label: `${location} · UTC${offset}`,
24
+ format(date: Date): string {
25
+ const text = formatter.format(date)
26
+ const dateOffset = localTimestamp(date, timeZone).slice(-6)
27
+ // A historical DST offset can differ from the current offset in the header.
28
+ return dateOffset === offset ? text : `${text} (UTC${dateOffset})`
29
+ },
30
+ }
31
+ }
32
+
33
+ export interface AgentMetadata {
34
+ model: string
35
+ thinking: string
36
+ }
37
+
38
+ /** Read the selected agent settings, not a classifier/TTS model used by a tool. */
39
+ export function agentMetadata(
40
+ pi: { getThinkingLevel(): string },
41
+ ctx: { model?: { provider: string; id: string } },
42
+ ): AgentMetadata {
43
+ let model = 'unknown'
44
+ let thinking = 'unknown'
45
+ // Logging must not block tool_call if settings are unavailable during a switch.
46
+ try {
47
+ const selected = ctx.model
48
+ if (selected) model = `${selected.provider}/${selected.id}`
49
+ } catch { /* Keep unknown metadata; the tool must still execute. */ }
50
+ try { thinking = pi.getThinkingLevel() || 'unknown' }
51
+ catch { /* Older runtimes may not expose the thinking getter. */ }
52
+ return { model, thinking }
53
+ }
54
+
55
+ /** Strip terminal controls as well as TSV separators from untrusted metadata. */
56
+ export function logField(value: string): string {
57
+ return value.replace(/[\x00-\x1f\x7f-\x9f]/g, ' ')
58
+ }
59
+
60
+ export function markdownCell(value: string): string {
61
+ return logField(value).replace(/[\\`*_[\]<>|]/g, '\\$&')
62
+ }