@saifymatteo/pi-extensions 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 +20 -0
- package/README.md +151 -2
- package/inventory.json +73 -0
- package/package.json +74 -4
- package/packages/pi-bg-bash/LICENSE +661 -0
- package/packages/pi-bg-bash/NOTICE.md +3 -0
- package/packages/pi-bg-bash/README.md +41 -0
- package/packages/pi-bg-bash/examples/settings.example.json +6 -0
- package/packages/pi-bg-bash/package.json +38 -0
- package/packages/pi-bg-bash/src/index.ts +969 -0
- package/packages/pi-bg-bash/src/lib/async-work.ts +27 -0
- package/packages/pi-bg-bash/test/bg-bash.test.mjs +34 -0
- package/packages/pi-claude-usage/LICENSE +661 -0
- package/packages/pi-claude-usage/NOTICE.md +3 -0
- package/packages/pi-claude-usage/README.md +27 -0
- package/packages/pi-claude-usage/package.json +35 -0
- package/packages/pi-claude-usage/src/index.ts +130 -0
- package/packages/pi-clipboard-image/LICENSE +661 -0
- package/packages/pi-clipboard-image/NOTICE.md +3 -0
- package/packages/pi-clipboard-image/README.md +27 -0
- package/packages/pi-clipboard-image/package.json +36 -0
- package/packages/pi-clipboard-image/src/index.ts +527 -0
- package/packages/pi-codeblock-borders/LICENSE +661 -0
- package/packages/pi-codeblock-borders/NOTICE.md +3 -0
- package/packages/pi-codeblock-borders/README.md +30 -0
- package/packages/pi-codeblock-borders/package.json +35 -0
- package/packages/pi-codeblock-borders/src/index.ts +302 -0
- package/packages/pi-codegraph/LICENSE +661 -0
- package/packages/pi-codegraph/NOTICE.md +5 -0
- package/packages/pi-codegraph/README.md +35 -0
- package/packages/pi-codegraph/examples/codegraph.example.json +4 -0
- package/packages/pi-codegraph/package.json +37 -0
- package/packages/pi-codegraph/src/index.ts +573 -0
- package/packages/pi-codegraph/test/codegraph.test.mjs +21 -0
- package/packages/pi-compact-tools/LICENSE +661 -0
- package/packages/pi-compact-tools/NOTICE.md +3 -0
- package/packages/pi-compact-tools/README.md +27 -0
- package/packages/pi-compact-tools/package.json +36 -0
- package/packages/pi-compact-tools/src/index.ts +1161 -0
- package/packages/pi-decisions/LICENSE +661 -0
- package/packages/pi-decisions/NOTICE.md +3 -0
- package/packages/pi-decisions/README.md +37 -0
- package/packages/pi-decisions/examples/service-models.example.json +27 -0
- package/packages/pi-decisions/package.json +37 -0
- package/packages/pi-decisions/src/index.ts +364 -0
- package/packages/pi-decisions/src/lib/service-models.ts +95 -0
- package/packages/pi-decisions/test/service-models.test.mjs +71 -0
- package/packages/pi-load-pi-dotenv/LICENSE +661 -0
- package/packages/pi-load-pi-dotenv/NOTICE.md +3 -0
- package/packages/pi-load-pi-dotenv/README.md +27 -0
- package/packages/pi-load-pi-dotenv/package.json +32 -0
- package/packages/pi-load-pi-dotenv/src/index.ts +66 -0
- package/packages/pi-model-go/LICENSE +661 -0
- package/packages/pi-model-go/NOTICE.md +5 -0
- package/packages/pi-model-go/README.md +33 -0
- package/packages/pi-model-go/package.json +36 -0
- package/packages/pi-model-go/src/index.ts +171 -0
- package/packages/pi-model-go/src/lib/dialog.ts +31 -0
- package/packages/pi-model-go/src/lib/model-go.ts +239 -0
- package/packages/pi-model-go/test/behavior.test.mjs +17 -0
- package/packages/pi-prestart-working/LICENSE +661 -0
- package/packages/pi-prestart-working/NOTICE.md +3 -0
- package/packages/pi-prestart-working/README.md +25 -0
- package/packages/pi-prestart-working/package.json +35 -0
- package/packages/pi-prestart-working/src/index.ts +86 -0
- package/packages/pi-prestart-working/test/behavior.test.mjs +56 -0
- package/packages/pi-root-guard/LICENSE +661 -0
- package/packages/pi-root-guard/NOTICE.md +3 -0
- package/packages/pi-root-guard/README.md +27 -0
- package/packages/pi-root-guard/package.json +32 -0
- package/packages/pi-root-guard/src/index.ts +202 -0
- package/packages/pi-session-finder/LICENSE +661 -0
- package/packages/pi-session-finder/NOTICE.md +3 -0
- package/packages/pi-session-finder/README.md +33 -0
- package/packages/pi-session-finder/package.json +36 -0
- package/packages/pi-session-finder/src/index.ts +306 -0
- package/packages/pi-session-finder/src/lib/dialog.ts +31 -0
- package/packages/pi-session-finder/test/behavior.test.mjs +18 -0
- package/packages/pi-session-id/LICENSE +661 -0
- package/packages/pi-session-id/NOTICE.md +3 -0
- package/packages/pi-session-id/README.md +31 -0
- package/packages/pi-session-id/package.json +35 -0
- package/packages/pi-session-id/src/index.ts +20 -0
- package/packages/pi-session-id/test/behavior.test.mjs +25 -0
- package/packages/pi-session-nav/LICENSE +661 -0
- package/packages/pi-session-nav/NOTICE.md +3 -0
- package/packages/pi-session-nav/README.md +32 -0
- package/packages/pi-session-nav/package.json +36 -0
- package/packages/pi-session-nav/src/index.ts +203 -0
- package/packages/pi-session-nav/src/lib/dialog.ts +31 -0
- package/packages/pi-session-nav/test/behavior.test.mjs +36 -0
- package/packages/pi-session-rename/LICENSE +661 -0
- package/packages/pi-session-rename/NOTICE.md +3 -0
- package/packages/pi-session-rename/README.md +31 -0
- package/packages/pi-session-rename/package.json +35 -0
- package/packages/pi-session-rename/src/index.ts +456 -0
- package/packages/pi-session-rename/test/behavior.test.mjs +10 -0
- package/packages/pi-session-retention/LICENSE +661 -0
- package/packages/pi-session-retention/NOTICE.md +3 -0
- package/packages/pi-session-retention/README.md +62 -0
- package/packages/pi-session-retention/package.json +36 -0
- package/packages/pi-session-retention/src/README.md +42 -0
- package/packages/pi-session-retention/src/core.ts +333 -0
- package/packages/pi-session-retention/src/dialog.ts +118 -0
- package/packages/pi-session-retention/src/index.ts +311 -0
- package/packages/pi-session-retention/src/lib/dialog.ts +31 -0
- package/packages/pi-session-retention/src/safety.ts +50 -0
- package/packages/pi-session-retention/test/behavior.test.mjs +42 -0
- package/packages/pi-skill-usage-log/LICENSE +661 -0
- package/packages/pi-skill-usage-log/NOTICE.md +3 -0
- package/packages/pi-skill-usage-log/README.md +32 -0
- package/packages/pi-skill-usage-log/package.json +36 -0
- package/packages/pi-skill-usage-log/src/index.ts +316 -0
- package/packages/pi-skill-usage-log/src/lib/dialog.ts +31 -0
- package/packages/pi-skill-usage-log/src/lib/usage-log.ts +62 -0
- package/packages/pi-skill-usage-log/test/behavior.test.mjs +34 -0
- package/packages/pi-tool-usage-log/LICENSE +661 -0
- package/packages/pi-tool-usage-log/NOTICE.md +5 -0
- package/packages/pi-tool-usage-log/README.md +32 -0
- package/packages/pi-tool-usage-log/package.json +36 -0
- package/packages/pi-tool-usage-log/src/index.ts +233 -0
- package/packages/pi-tool-usage-log/src/lib/dialog.ts +31 -0
- package/packages/pi-tool-usage-log/src/lib/usage-log.ts +62 -0
- package/packages/pi-tool-usage-log/src/skill-usage-log.ts +316 -0
- package/packages/pi-tool-usage-log/test/behavior.test.mjs +23 -0
- package/packages/pi-tts/LICENSE +661 -0
- package/packages/pi-tts/NOTICE.md +3 -0
- package/packages/pi-tts/README.md +37 -0
- package/packages/pi-tts/examples/service-models.example.json +27 -0
- package/packages/pi-tts/package.json +37 -0
- package/packages/pi-tts/src/index.ts +135 -0
- package/packages/pi-tts/src/lib/service-models.ts +95 -0
- package/packages/pi-tts/test/tts-output.test.mjs +47 -0
- package/packages/pi-tts/test/tts.test.mjs +57 -0
- package/packages/pi-user-image-previews/LICENSE +661 -0
- package/packages/pi-user-image-previews/NOTICE.md +3 -0
- package/packages/pi-user-image-previews/README.md +27 -0
- package/packages/pi-user-image-previews/package.json +36 -0
- package/packages/pi-user-image-previews/src/index.ts +168 -0
- package/packages/pi-vision/LICENSE +661 -0
- package/packages/pi-vision/NOTICE.md +5 -0
- package/packages/pi-vision/README.md +43 -0
- package/packages/pi-vision/examples/vision-toolkit.example.json +12 -0
- package/packages/pi-vision/package.json +38 -0
- package/packages/pi-vision/skills/vision-tools/LICENSE +21 -0
- package/packages/pi-vision/skills/vision-tools/SKILL.md +134 -0
- package/packages/pi-vision/skills/vision-tools/agents/openai.yaml +4 -0
- package/packages/pi-vision/skills/vision-tools/bin/crop +94 -0
- package/packages/pi-vision/skills/vision-tools/bin/detect +18 -0
- package/packages/pi-vision/skills/vision-tools/bin/glance +98 -0
- package/packages/pi-vision/skills/vision-tools/bin/ground +18 -0
- package/packages/pi-vision/skills/vision-tools/bin/trace +134 -0
- package/packages/pi-vision/skills/vision-tools/bin/vision-doctor +168 -0
- package/packages/pi-vision/skills/vision-tools/detect.py +56 -0
- package/packages/pi-vision/skills/vision-tools/ground.py +216 -0
- package/packages/pi-vision/skills/vision-tools/references/gui.md +93 -0
- package/packages/pi-vision/skills/vision-tools/references/long-screenshot-ocr.md +103 -0
- package/packages/pi-vision/skills/vision-tools/references/restore-graphic.md +92 -0
- package/packages/pi-vision/skills/vision-tools/references/restore-structure.md +47 -0
- package/packages/pi-vision/skills/vision-tools/references/restore-ui.md +200 -0
- package/packages/pi-vision/skills/vision-tools/scripts/dominant_colors.py +224 -0
- package/packages/pi-vision/skills/vision-tools/scripts/extract_fg.py +278 -0
- package/packages/pi-vision/skills/vision-tools/scripts/html_shot.py +111 -0
- package/packages/pi-vision/skills/vision-tools/scripts/long_screenshot_ocr.py +1245 -0
- package/packages/pi-vision/skills/vision-tools/scripts/pixel_diff.py +88 -0
- package/packages/pi-vision/skills/vision-tools/vision_client.py +581 -0
- package/packages/pi-vision/src/index.ts +728 -0
- package/packages/pi-vision/test/vision.test.mjs +30 -0
- package/packages/pi-wait/LICENSE +661 -0
- package/packages/pi-wait/NOTICE.md +3 -0
- package/packages/pi-wait/README.md +37 -0
- package/packages/pi-wait/package.json +37 -0
- package/packages/pi-wait/src/index.ts +88 -0
- package/packages/pi-wait/src/lib/async-work.ts +27 -0
- package/packages/pi-wait/test/wait.test.mjs +43 -0
- package/packages/pi-zvec/LICENSE +661 -0
- package/packages/pi-zvec/NOTICE.md +3 -0
- package/packages/pi-zvec/README.md +37 -0
- package/packages/pi-zvec/examples/zvec.example.json +5 -0
- package/packages/pi-zvec/package.json +37 -0
- package/packages/pi-zvec/src/index.ts +754 -0
- package/packages/pi-zvec/test/zvec.test.mjs +19 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# pi-skill-usage-log
|
|
2
|
+
|
|
3
|
+
Passively record skill loads and subsequent tool activity, then show cross-project usage without adding the report to conversation context.
|
|
4
|
+
|
|
5
|
+
## Running locally
|
|
6
|
+
|
|
7
|
+
From the monorepo root:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pi -e ./packages/pi-skill-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
|
+
- `/skill-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 TSV rows to `<agent-dir>/skill-usage.log`; `<agent-dir>` is `PI_CODING_AGENT_DIR` or `~/.pi/agent`. No configuration file. Both legacy six-column and model/thinking eight-column rows are read. Loaded/used events are deduplicated per session.
|
|
23
|
+
|
|
24
|
+
## Privacy and operations
|
|
25
|
+
|
|
26
|
+
Log stores timestamps, session IDs, working directories, skill names/paths and selected agent model/thinking level. It is persistent local history with no rotation or removal feature. No prompt text, tool result content or network requests are logged/sent. A successful SKILL.md read followed by another non-think tool call in the same request counts as use; this is a heuristic, not proof. Merely loading this package enables tracking.
|
|
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; tracking uses Pi lifecycle and tool hooks. Installed skill listing depends on `getCommands()` skill metadata. Older historical counts use a narrower same-turn heuristic and cannot be repaired.
|
|
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.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@saifymatteo/pi-skill-usage-log",
|
|
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": "Log which skills were actually used; /skill-usage summary.",
|
|
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-skill-usage-log"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { loadExtension } from '../../../test/load-extension.mjs';
|
|
4
|
+
const { buildTracker, summarizeUsage } = loadExtension(new URL('../src/index.ts', import.meta.url), {
|
|
5
|
+
'@earendil-works/pi-coding-agent': {}, '@earendil-works/pi-tui': {},
|
|
6
|
+
});
|
|
7
|
+
test('skill use requires follow-up activity in the request and is deduped per session', () => {
|
|
8
|
+
const events = [], tracker = buildTracker(e => events.push(e));
|
|
9
|
+
tracker.handleRequestStart();
|
|
10
|
+
tracker.handleSkillRead('C:\\skills\\example\\SKILL.md');
|
|
11
|
+
assert.equal(events.length, 1);
|
|
12
|
+
assert.equal(events[0].event, 'skill:load');
|
|
13
|
+
assert.equal(tracker.handleTurnEnd().length, 0);
|
|
14
|
+
tracker.handleToolCall('think');
|
|
15
|
+
assert.equal(tracker.handleTurnEnd().length, 0);
|
|
16
|
+
tracker.handleToolCall('read', { model: 'p/m', thinking: 'low' });
|
|
17
|
+
assert.equal(tracker.handleTurnEnd()[0].event, 'skill:complete');
|
|
18
|
+
assert.equal(tracker.handleTurnEnd().length, 0);
|
|
19
|
+
tracker.handleRequestStart();
|
|
20
|
+
tracker.handleSkillRead('/skills/example/SKILL.md');
|
|
21
|
+
tracker.handleToolCall('bash');
|
|
22
|
+
assert.equal(tracker.handleTurnEnd().length, 0);
|
|
23
|
+
assert.equal(events.length, 2);
|
|
24
|
+
});
|
|
25
|
+
test('skill summary dedupes sessions, rejects malformed rows and includes unused skills', () => {
|
|
26
|
+
const row = '2026-01-01T00:00:00.000Z\tsid\tcwd\tskill:complete\texample\t/path';
|
|
27
|
+
const removed = '2026-01-01T00:00:00.000Z\tsid\tcwd\tskill:complete\tremoved\t/path';
|
|
28
|
+
const summary = summarizeUsage(row + '\n' + row + '\n' + removed + '\nbad', ['example', 'unused']);
|
|
29
|
+
assert.equal(summary.skipped, 1);
|
|
30
|
+
assert.equal(summary.skills.length, 2, 'removed skills are filtered out');
|
|
31
|
+
assert.equal(summary.skills[0].uses, 1);
|
|
32
|
+
assert.equal(summary.skills[1].skill, 'unused');
|
|
33
|
+
assert.equal(summary.skills[1].lastUsed, null);
|
|
34
|
+
});
|