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

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.
@@ -0,0 +1,316 @@
1
+ /**
2
+ * skill-usage-log.ts — track skill *use*, not just reads, so unused skills
3
+ * can be identified for removal.
4
+ *
5
+ * A skill is considered USED when a successful SKILL.md read is followed by
6
+ * another tool call while answering the same request, even in a later model
7
+ * turn. This is an activity heuristic, not proof the instructions were followed.
8
+ * A skill that is read then abandoned (no follow-up tool) is NOT counted.
9
+ *
10
+ * Emits two event kinds to <agent-home>/skill-usage.log
11
+ * (defaults to ~/.pi/agent, or PI_CODING_AGENT_DIR):
12
+ * skill:load — SKILL.md read (deduped once per session)
13
+ * skill:complete — skill loaded AND had follow-up tool activity this request
14
+ * Both events are deduped once per session. New TSV rows append the selected
15
+ * agent model and thinking level to the legacy six fields. Timestamps use the
16
+ * runtime's local timezone with explicit offsets; old UTC rows remain readable.
17
+ *
18
+ * /skill-usage summarizes the log across projects, sorted by used-session count.
19
+ * It lists every installed skill (pi.getCommands() skill entries), so never-loaded skills show as 0/never.
20
+ * Historical rows retain the old counting heuristic; they cannot be repaired.
21
+ * `buildTracker()` is pure for testing; the default export wires it up to pi.
22
+ */
23
+ import type { ExtensionAPI, ExtensionContext, Theme } from '@earendil-works/pi-coding-agent'
24
+ import { getMarkdownTheme } from '@earendil-works/pi-coding-agent'
25
+ import { Markdown, matchesKey, truncateToWidth, visibleWidth, type Component, type TUI, type TuiMouseEvent } from '@earendil-works/pi-tui'
26
+ import { appendFileSync, mkdirSync } from 'node:fs'
27
+ import { readFile } from 'node:fs/promises'
28
+ import { homedir } from 'node:os'
29
+ import { dirname, join, resolve } from 'node:path'
30
+ import { agentMetadata, localTimestamp, logField, markdownCell, usageTimeFormat } from './lib/usage-log.ts'
31
+ import { DIALOG_OVERLAY, dialogRows, wheelAsArrows } from './lib/dialog.ts'
32
+
33
+ export interface TrackerEvent {
34
+ event: 'skill:load' | 'skill:complete'
35
+ skill: string
36
+ path: string
37
+ model?: string
38
+ thinking?: string
39
+ }
40
+
41
+ export interface SkillTracker {
42
+ handleRequestStart(): void
43
+ handleToolCall(tool: string | undefined, metadata?: { model: string; thinking: string }): void
44
+ handleSkillRead(path: string, metadata?: { model: string; thinking: string }): void
45
+ handleTurnEnd(): TrackerEvent[]
46
+ }
47
+
48
+ /** Pure skill-usage state machine — no pi, no fs. Testable. */
49
+ export function buildTracker(onEvent?: (e: TrackerEvent) => void): SkillTracker {
50
+ const sessionUsed = new Set<string>()
51
+ const sessionLoaded = new Set<string>()
52
+ const requestLoaded = new Map<string, string>()
53
+ const requestUsed = new Map<string, { model: string; thinking: string } | undefined>()
54
+
55
+ return {
56
+ handleRequestStart() {
57
+ requestLoaded.clear()
58
+ requestUsed.clear()
59
+ },
60
+ handleToolCall(tool, metadata) {
61
+ if (tool && tool !== 'think') {
62
+ for (const skill of requestLoaded.keys()) {
63
+ if (!requestUsed.has(skill)) requestUsed.set(skill, metadata && { ...metadata })
64
+ }
65
+ }
66
+ },
67
+ handleSkillRead(path, metadata) {
68
+ const norm = normalize(path)
69
+ // Same SKILL.md rule as compact-tools; keep legacy standalone skills too.
70
+ const skill = norm.match(/(?:^|\/)([^/]+)\/SKILL\.md$/i)?.[1] ??
71
+ norm.match(/(?:^|\/)skills\/([^/]+)\.md$/i)?.[1]
72
+ if (!skill) return
73
+ requestLoaded.set(skill, path)
74
+ if (!sessionLoaded.has(skill)) {
75
+ sessionLoaded.add(skill)
76
+ onEvent?.({ event: 'skill:load', skill, path, ...metadata })
77
+ }
78
+ },
79
+ handleTurnEnd() {
80
+ const out: TrackerEvent[] = []
81
+ for (const [skill, metadata] of requestUsed) {
82
+ if (!sessionUsed.has(skill)) {
83
+ sessionUsed.add(skill)
84
+ out.push({ event: 'skill:complete', skill, path: requestLoaded.get(skill)!, ...metadata })
85
+ }
86
+ }
87
+ out.forEach((event) => onEvent?.(event))
88
+ return out
89
+ },
90
+ }
91
+ }
92
+
93
+ function normalize(p: string): string {
94
+ return p.replaceAll('\\', '/')
95
+ }
96
+
97
+ function isModelInvocationDisabled(content: string): boolean {
98
+ const frontmatter = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1]
99
+ return frontmatter?.split(/\r?\n/).some((line) => /^disable-model-invocation:\s*true\s*(?:#.*)?$/i.test(line)) ?? false
100
+ }
101
+
102
+ export interface UsageRow {
103
+ skill: string
104
+ loads: number
105
+ uses: number
106
+ lastUsed: string | null
107
+ }
108
+
109
+ /** Counts sessions, not tool calls; accepts legacy six- and metadata eight-column TSV.
110
+ * When supplied, `installed` filters removed skills and adds unseen skills as zero rows. */
111
+ export function summarizeUsage(log: string, installed?: string[]): { skills: UsageRow[]; skipped: number } {
112
+ const { skills, skipped } = summarizeLog(log)
113
+ if (installed === undefined) return { skills, skipped }
114
+ const available = new Set(installed)
115
+ const filtered = skills.filter((row) => available.has(row.skill))
116
+ const seen = new Set(filtered.map((row) => row.skill))
117
+ const unused = [...available].filter((skill) => !seen.has(skill)).sort((a, b) => a.localeCompare(b))
118
+ return { skills: [...filtered, ...unused.map((skill) => ({ skill, loads: 0, uses: 0, lastUsed: null }))], skipped }
119
+ }
120
+
121
+ function summarizeLog(log: string, byAgent = false) {
122
+ const rows = new Map<string, { row: UsageRow & { model?: string; thinking?: string }; loads: Set<string>; uses: Set<string> }>()
123
+ let skipped = 0
124
+ for (const line of log.split(/\r?\n/)) {
125
+ if (!line) continue
126
+ const fields = line.split('\t')
127
+ const [timestamp, sid, , event, skill] = fields
128
+ const time = Date.parse(timestamp)
129
+ if ((fields.length !== 6 && fields.length !== 8) || !Number.isFinite(time) || !skill ||
130
+ /[\x00-\x1f\x7f-\x9f]/.test(skill) ||
131
+ (event !== 'skill:load' && event !== 'skill:complete')) {
132
+ skipped++
133
+ continue
134
+ }
135
+ const model = fields[6] || 'unknown'
136
+ const thinking = fields[7] || 'unknown'
137
+ const group = byAgent ? JSON.stringify([skill, model, thinking]) : skill
138
+ let entry = rows.get(group)
139
+ if (!entry) {
140
+ entry = { row: { skill, loads: 0, uses: 0, lastUsed: null, ...(byAgent ? { model, thinking } : {}) }, loads: new Set(), uses: new Set() }
141
+ rows.set(group, entry)
142
+ }
143
+ // Old logs can have an empty sid; do not merge their unrelated uses.
144
+ const key = sid || timestamp
145
+ if (event === 'skill:load') {
146
+ entry.loads.add(key)
147
+ entry.row.loads = entry.loads.size
148
+ } else {
149
+ entry.uses.add(key)
150
+ entry.row.uses = entry.uses.size
151
+ const usedAt = new Date(time).toISOString()
152
+ if (!entry.row.lastUsed || usedAt > entry.row.lastUsed) entry.row.lastUsed = usedAt
153
+ }
154
+ }
155
+ return {
156
+ skills: [...rows.values()].map(({ row }) => row)
157
+ .sort((a, b) => b.uses - a.uses || a.skill.localeCompare(b.skill)),
158
+ skipped,
159
+ }
160
+ }
161
+
162
+ /** UI-only viewport: opening/closing it never appends session content. */
163
+ export class SkillUsageDialog implements Component {
164
+ private offset = 0
165
+ private pageSize = 1
166
+ private lineCount = 0
167
+ private readonly document: Markdown
168
+
169
+ constructor(private readonly tui: TUI, private readonly theme: Theme, report: string, private readonly done: () => void, private readonly title = 'Skill usage') {
170
+ this.document = new Markdown(report, 0, 0, getMarkdownTheme())
171
+ }
172
+
173
+ invalidate(): void { this.document.invalidate() }
174
+
175
+ render(width: number): string[] {
176
+ if (width < 1) return []
177
+ const bodyWidth = Math.max(1, width - 4)
178
+ const content = this.document.render(bodyWidth)
179
+ this.lineCount = content.length
180
+ // Keep the footer visible on short terminals, including after a resize.
181
+ // Fixed lines: 2 borders, footer.
182
+ this.pageSize = dialogRows(this.tui, 3)
183
+ this.offset = Math.max(0, Math.min(this.offset, this.lineCount - this.pageSize))
184
+ const border = (text: string) => this.theme.fg('border', text)
185
+ const row = (text: string) => {
186
+ const fitted = truncateToWidth(text, bodyWidth, '…')
187
+ return border('┃') + ` ${fitted}${' '.repeat(Math.max(0, bodyWidth - visibleWidth(fitted)))} ` + border('┃')
188
+ }
189
+ const title = truncateToWidth(` ${this.title} `, Math.max(0, width - 2), '…')
190
+ const range = `${this.lineCount ? this.offset + 1 : 0}–${Math.min(this.offset + this.pageSize, this.lineCount)}/${this.lineCount}`
191
+ const lines = [border(`┏${title}${'━'.repeat(Math.max(0, width - 2 - visibleWidth(title)))}┓`),
192
+ ...Array.from({ length: this.pageSize }, (_, i) => row(content[this.offset + i] ?? '')),
193
+ row(this.theme.fg('dim', `Esc close · ↑↓ scroll · PgUp/PgDn · ${range}`)),
194
+ border(`┗${'━'.repeat(Math.max(0, width - 2))}┛`)]
195
+ return lines.map((line) => truncateToWidth(line, width, ''))
196
+ }
197
+
198
+ handleMouse(event: TuiMouseEvent) { return wheelAsArrows(event, (key) => this.handleInput(key)) }
199
+
200
+ handleInput(data: string): void {
201
+ if (matchesKey(data, 'escape') || matchesKey(data, 'ctrl+c') || matchesKey(data, 'enter')) return this.done()
202
+ if (matchesKey(data, 'up')) this.offset--
203
+ else if (matchesKey(data, 'down')) this.offset++
204
+ else if (matchesKey(data, 'pageUp')) this.offset -= this.pageSize
205
+ else if (matchesKey(data, 'pageDown')) this.offset += this.pageSize
206
+ else if (matchesKey(data, 'home')) this.offset = 0
207
+ else if (matchesKey(data, 'end')) this.offset = this.lineCount
208
+ else return
209
+ this.offset = Math.max(0, Math.min(this.offset, this.lineCount - this.pageSize))
210
+ this.tui.requestRender()
211
+ }
212
+ }
213
+
214
+ export default function register(pi: ExtensionAPI): void {
215
+ const agentDir = process.env.PI_CODING_AGENT_DIR || join(homedir(), '.pi', 'agent')
216
+ const logPath = join(agentDir.replace(/^~(?=$|[\\/])/, homedir()), 'skill-usage.log')
217
+ let currentSid: string | undefined
218
+ let currentCwd = ''
219
+ let tracker: SkillTracker
220
+ let logError: string | undefined
221
+ let warned = false
222
+
223
+ function forSession(ctx: ExtensionContext): SkillTracker {
224
+ const sid = ctx.sessionManager.getSessionId()
225
+ currentCwd = ctx.cwd
226
+ if (sid !== currentSid || !tracker) {
227
+ currentSid = sid
228
+ warned = false
229
+ logError = undefined
230
+ tracker = buildTracker((e) => {
231
+ try {
232
+ mkdirSync(dirname(logPath), { recursive: true })
233
+ const fields = [localTimestamp(), sid, currentCwd, e.event, e.skill, normalize(e.path), e.model ?? 'unknown', e.thinking ?? 'unknown']
234
+ // Keep unusual filenames and working directories from corrupting TSV rows.
235
+ appendFileSync(logPath, fields.map(logField).join('\t') + '\n')
236
+ } catch (error) {
237
+ logError = error instanceof Error ? error.message : String(error)
238
+ }
239
+ })
240
+ }
241
+ return tracker
242
+ }
243
+
244
+ pi.on('session_start', (_event, ctx) => { forSession(ctx) })
245
+ pi.on('before_agent_start', (_event, ctx) => { forSession(ctx).handleRequestStart() })
246
+ pi.on('tool_call', (event, ctx) => { forSession(ctx).handleToolCall(event.toolName, agentMetadata(pi, ctx)) })
247
+ pi.on('tool_result', (event, ctx) => {
248
+ if (event.toolName !== 'read' || event.isError || typeof event.input.path !== 'string') return
249
+ const raw = event.input.path.replace(/^~(?=$|[\\/])/, homedir())
250
+ forSession(ctx).handleSkillRead(resolve(ctx.cwd, raw), agentMetadata(pi, ctx))
251
+ })
252
+ pi.on('turn_end', (_event, ctx) => {
253
+ forSession(ctx).handleTurnEnd()
254
+ if (logError && !warned && ctx.hasUI) {
255
+ warned = true
256
+ ctx.ui.notify(`Skill usage could not be logged to ${logPath}: ${logError}`, 'warning')
257
+ }
258
+ })
259
+
260
+ pi.registerCommand('skill-usage', {
261
+ description: 'List historical skill usage, agent models, thinking levels and local last-used times',
262
+ handler: async (args, ctx) => {
263
+ if (ctx.mode !== 'tui') {
264
+ ctx.ui.notify('Skill usage requires interactive terminal mode; no report was added to the transcript.', 'info')
265
+ return
266
+ }
267
+ if (args.trim()) {
268
+ ctx.ui.notify('Usage: /skill-usage (all logged sessions and projects)', 'info')
269
+ return
270
+ }
271
+ try {
272
+ let log = ''
273
+ try {
274
+ log = await readFile(logPath, 'utf8')
275
+ } catch (error) {
276
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
277
+ }
278
+ const skillCommands = pi.getCommands().filter((command) => command.source === 'skill')
279
+ const installed = skillCommands.map((command) => command.name.replace(/^skill:/, ''))
280
+ const invocationDisabled = new Map<string, boolean>()
281
+ await Promise.all(skillCommands.map(async (command, index) => {
282
+ const path = command.sourceInfo?.path
283
+ if (typeof path !== 'string') return
284
+ try { invocationDisabled.set(installed[index], isModelInvocationDisabled(await readFile(path, 'utf8'))) }
285
+ catch { /* Missing/unreadable skill metadata is reported as invocation-enabled. */ }
286
+ }))
287
+ const { skills, skipped } = summarizeUsage(log, installed)
288
+ const time = usageTimeFormat()
289
+ const lines = ['Skill usage — all logged sessions and projects', '',
290
+ 'Counts are unique sessions, not tool calls. Used = read followed by tool activity in the same request.',
291
+ 'Older entries used a same-model-turn rule and may undercount. User invoke shows disable-model-invocation from skill frontmatter; 0 / never = not used since logging began.', '',
292
+ `| Skill | User invoke | Loaded sessions | Used sessions | Last used (${markdownCell(time.label)}) |`,
293
+ '| --- | --- | ---: | ---: | --- |']
294
+ for (const row of skills) {
295
+ lines.push(`| ${markdownCell(row.skill)} | ${invocationDisabled.get(row.skill) ? 'Yes' : '-'} | ${row.loads} | ${row.uses} | ${row.lastUsed ? markdownCell(time.format(new Date(row.lastUsed))) : 'never'} |`)
296
+ }
297
+ if (skills.some((row) => row.loads)) {
298
+ lines.push('', 'By selected agent model and thinking level (unique sessions within each group; groups may overlap):',
299
+ 'Legacy metadata is unknown. Only the first load/use per session is logged; this is not every model that later used the skill.', '',
300
+ '| Skill | Model | Thinking | Loaded sessions | Used sessions |',
301
+ '| --- | --- | --- | ---: | ---: |')
302
+ for (const row of summarizeLog(log, true).skills.filter((row) => installed.includes(row.skill))) {
303
+ lines.push(`| ${markdownCell(row.skill)} | ${markdownCell(row.model!)} | ${markdownCell(row.thinking!)} | ${row.loads} | ${row.uses} |`)
304
+ }
305
+ }
306
+ if (!skills.some((row) => row.loads)) lines.push('', 'No skill usage recorded yet.')
307
+ if (skipped) lines.push('', `Skipped ${skipped} malformed log row(s).`)
308
+ lines.push('', `Log: ${normalize(logPath).replace(/[\x00-\x1f\x7f-\x9f]/g, ' ')}`)
309
+ await ctx.ui.custom<void>((tui, theme, _keybindings, done) =>
310
+ new SkillUsageDialog(tui, theme, lines.join('\n'), done), DIALOG_OVERLAY)
311
+ } catch (error) {
312
+ ctx.ui.notify(`Could not show skill usage: ${error instanceof Error ? error.message : String(error)}`, 'error')
313
+ }
314
+ },
315
+ })
316
+ }