@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/LICENSE +661 -0
- package/NOTICE.md +5 -0
- package/README.md +31 -2
- package/package.json +34 -4
- package/src/index.ts +233 -0
- package/src/lib/dialog.ts +31 -0
- package/src/lib/usage-log.ts +62 -0
- package/src/skill-usage-log.ts +316 -0
|
@@ -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
|
+
}
|