pi-extensions 0.1.22 → 0.1.23
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/.github/workflows/skill-apply-optimize.yml +19 -0
- package/.github/workflows/skill-review.yml +17 -0
- package/extending-pi/SKILL.md +43 -12
- package/package.json +2 -6
- package/usage-extension/CHANGELOG.md +4 -0
- package/usage-extension/README.md +18 -2
- package/usage-extension/index.ts +168 -99
- package/usage-extension/package.json +1 -1
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
name: Apply Skill Optimization
|
|
2
|
+
on:
|
|
3
|
+
issue_comment:
|
|
4
|
+
types: [created]
|
|
5
|
+
|
|
6
|
+
jobs:
|
|
7
|
+
apply:
|
|
8
|
+
if: >-
|
|
9
|
+
github.event.issue.pull_request &&
|
|
10
|
+
contains(github.event.comment.body, '/apply-optimize')
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
permissions:
|
|
13
|
+
pull-requests: write
|
|
14
|
+
contents: write
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: tesslio/skill-review-and-optimize@d81583861aaf29d1da7f10e6539efef4e27b0dd5
|
|
18
|
+
with:
|
|
19
|
+
mode: 'apply'
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
name: Skill Review & Optimize
|
|
2
|
+
on:
|
|
3
|
+
pull_request:
|
|
4
|
+
paths: ['**/SKILL.md']
|
|
5
|
+
|
|
6
|
+
jobs:
|
|
7
|
+
review:
|
|
8
|
+
runs-on: ubuntu-latest
|
|
9
|
+
permissions:
|
|
10
|
+
pull-requests: write
|
|
11
|
+
contents: read
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
- uses: tesslio/skill-review-and-optimize@d81583861aaf29d1da7f10e6539efef4e27b0dd5
|
|
15
|
+
with:
|
|
16
|
+
optimize: 'true'
|
|
17
|
+
tessl-token: ${{ secrets.TESSL_API_TOKEN }}
|
package/extending-pi/SKILL.md
CHANGED
|
@@ -1,30 +1,61 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: extending-pi
|
|
3
|
-
description: Guide for extending Pi — decide between skills, extensions, prompt templates, themes, context files, or custom models, then
|
|
3
|
+
description: Guide for extending Pi — decide between skills, extensions, prompt templates, themes, context files, or custom models, then scaffold files, configure manifests, and package them. Use when someone wants to extend Pi, add capabilities, create a skill, build an extension, make a Pi package, scaffold extension files, or configure a manifest.json.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Extending Pi
|
|
7
7
|
|
|
8
|
-
Help the user decide what to build and
|
|
8
|
+
Help the user decide what to build, scaffold the right files, and point to detailed guidance.
|
|
9
9
|
|
|
10
10
|
## What to build
|
|
11
11
|
|
|
12
|
-
| Goal | Build a… | Where |
|
|
13
|
-
|
|
14
|
-
| Teach Pi a workflow or how to use a tool/API/CLI | **Skill** | Read `skill-creator/SKILL.md` for detailed guidance |
|
|
15
|
-
| Give Pi a new tool, command, or runtime behavior | **Extension** | Read Pi docs: `docs/extensions.md` |
|
|
16
|
-
| Reuse a prompt pattern with variables | **Prompt template** | Read Pi docs: `docs/prompt-templates.md` |
|
|
17
|
-
| Set project-wide coding guidelines | **Context file** | `AGENTS.md` in project root or `.pi/agent/` — just markdown |
|
|
18
|
-
| Change Pi's appearance | **Theme** | Read Pi docs: `docs/themes.md` |
|
|
19
|
-
| Add a model or provider | **Custom model** | Read Pi docs: `docs/models.md` (JSON) or `docs/custom-provider.md` (extension) |
|
|
20
|
-
| Share any of the above | **Package** | Read Pi docs: `docs/packages.md` |
|
|
12
|
+
| Goal | Build a… | Key files to create | Where |
|
|
13
|
+
|------|----------|-------------------|-------|
|
|
14
|
+
| Teach Pi a workflow or how to use a tool/API/CLI | **Skill** | `SKILL.md` with YAML frontmatter + markdown body | Read `skill-creator/SKILL.md` for detailed guidance |
|
|
15
|
+
| Give Pi a new tool, command, or runtime behavior | **Extension** | `manifest.json` + `src/index.ts` entry point | Read Pi docs: `docs/extensions.md` |
|
|
16
|
+
| Reuse a prompt pattern with variables | **Prompt template** | `.md` file with `{{variable}}` placeholders | Read Pi docs: `docs/prompt-templates.md` |
|
|
17
|
+
| Set project-wide coding guidelines | **Context file** | `AGENTS.md` in project root or `.pi/agent/` — just markdown | No extra docs needed |
|
|
18
|
+
| Change Pi's appearance | **Theme** | `theme.json` with color and font definitions | Read Pi docs: `docs/themes.md` |
|
|
19
|
+
| Add a model or provider | **Custom model** | `models.json` or extension with provider registration | Read Pi docs: `docs/models.md` (JSON) or `docs/custom-provider.md` (extension) |
|
|
20
|
+
| Share any of the above | **Package** | `manifest.json` with dependencies and entry points | Read Pi docs: `docs/packages.md` |
|
|
21
21
|
|
|
22
22
|
## Skill vs Extension — the fuzzy boundary
|
|
23
23
|
|
|
24
|
-
If `bash` + instructions can do it, prefer a **
|
|
24
|
+
If `bash` + instructions can do it, prefer a **Skill** (simpler, no code to maintain). If you need event hooks, typed tools, UI components, or policy enforcement, use an **Extension**.
|
|
25
25
|
|
|
26
26
|
Examples:
|
|
27
27
|
- "Pi should know our deploy process" → **Skill** (workflow instructions)
|
|
28
28
|
- "Pi should confirm before `rm -rf`" → **Extension** (event interception)
|
|
29
29
|
- "Pi should use Brave Search" → **Skill** (instructions + CLI scripts)
|
|
30
30
|
- "Pi should have a structured `db_query` tool" → **Extension** (registerTool)
|
|
31
|
+
|
|
32
|
+
## Minimal working examples
|
|
33
|
+
|
|
34
|
+
**Skill** — place in `.pi/skills/my-skill/SKILL.md`:
|
|
35
|
+
```markdown
|
|
36
|
+
---
|
|
37
|
+
name: my-skill
|
|
38
|
+
description: Does X when the user asks to Y.
|
|
39
|
+
---
|
|
40
|
+
# My Skill
|
|
41
|
+
Step 1: ...
|
|
42
|
+
Step 2: ...
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
**Extension** — create `manifest.json` + `src/index.ts`:
|
|
46
|
+
```json
|
|
47
|
+
{ "name": "my-extension", "version": "0.1.0", "entry": "src/index.ts" }
|
|
48
|
+
```
|
|
49
|
+
```typescript
|
|
50
|
+
import { registerTool } from "@anthropic/pi-sdk";
|
|
51
|
+
registerTool("my_tool", { description: "..." }, async (input) => { /* ... */ });
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Quick-start steps
|
|
55
|
+
|
|
56
|
+
1. **Pick the artifact type** from the table above.
|
|
57
|
+
2. **Scaffold the files** — create the key files using the minimal examples above.
|
|
58
|
+
3. **Validate locally**:
|
|
59
|
+
- Skills: place `SKILL.md` in `.pi/skills/<name>/` and invoke the skill — if it doesn't trigger, check that `name` and `description` in frontmatter are set correctly.
|
|
60
|
+
- Extensions: run `pi ext install .` — if it fails with "missing entry", verify the `entry` path in `manifest.json` points to an existing file.
|
|
61
|
+
4. **Package and share** — follow `docs/packages.md` to bundle and publish.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-extensions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.23",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"private": false,
|
|
6
6
|
"keywords": [
|
|
@@ -15,15 +15,11 @@
|
|
|
15
15
|
"./arcade/tetris.ts",
|
|
16
16
|
"./arcade/mario-not/mario-not.ts",
|
|
17
17
|
"./code-actions/index.ts",
|
|
18
|
-
"./control/control.ts",
|
|
19
18
|
"./files-widget/index.ts",
|
|
20
19
|
"./raw-paste/index.ts",
|
|
21
20
|
"./ralph-wiggum/index.ts",
|
|
22
|
-
"./review/review.ts",
|
|
23
|
-
"./relaunch/index.ts",
|
|
24
21
|
"./tab-status/tab-status.ts",
|
|
25
|
-
"./usage-extension/index.ts"
|
|
26
|
-
"./ready-status/ready-status.ts"
|
|
22
|
+
"./usage-extension/index.ts"
|
|
27
23
|
],
|
|
28
24
|
"skills": [
|
|
29
25
|
"./extending-pi/SKILL.md",
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.4 - 2026-04-09
|
|
4
|
+
- Scan session files recursively so nested subagent runs are included in `/usage`
|
|
5
|
+
- Add deduped vs raw counting modes to compare copied branch history against raw file totals
|
|
6
|
+
|
|
3
7
|
## 0.1.3 - 2026-02-03
|
|
4
8
|
- Add preview image metadata for the extension listing.
|
|
5
9
|
|
|
@@ -7,7 +7,7 @@ A Pi extension that displays aggregated usage statistics across all sessions.
|
|
|
7
7
|
## Compatibility
|
|
8
8
|
|
|
9
9
|
- **Pi version:** 0.42.4+
|
|
10
|
-
- **Last updated:** 2026-
|
|
10
|
+
- **Last updated:** 2026-04-09
|
|
11
11
|
|
|
12
12
|
## Installation
|
|
13
13
|
|
|
@@ -65,6 +65,15 @@ In Pi, run:
|
|
|
65
65
|
|
|
66
66
|
Use `Tab` or `←`/`→` to switch between periods.
|
|
67
67
|
|
|
68
|
+
### Count Modes
|
|
69
|
+
|
|
70
|
+
| Mode | Definition |
|
|
71
|
+
|------|------------|
|
|
72
|
+
| **Deduped** | Default. Deduplicates copied assistant history across branched session files |
|
|
73
|
+
| **Raw** | Counts every assistant message found in every session file |
|
|
74
|
+
|
|
75
|
+
Both modes scan nested session files recursively, so subagent runs are included.
|
|
76
|
+
|
|
68
77
|
### Timezone
|
|
69
78
|
|
|
70
79
|
Time periods are calculated in the local timezone where Pi runs. If you want to override it, set the `TZ` environment variable (IANA timezone, e.g. `TZ=UTC` or `TZ=America/New_York`) before launching Pi.
|
|
@@ -87,6 +96,9 @@ Time periods are calculated in the local timezone where Pi runs. If you want to
|
|
|
87
96
|
| Key | Action |
|
|
88
97
|
|-----|--------|
|
|
89
98
|
| `Tab` / `←` `→` | Switch time period |
|
|
99
|
+
| `m` | Cycle count mode |
|
|
100
|
+
| `d` | Switch to deduped mode |
|
|
101
|
+
| `r` | Switch to raw mode |
|
|
90
102
|
| `↑` `↓` | Select provider |
|
|
91
103
|
| `Enter` / `Space` | Expand/collapse provider to show models |
|
|
92
104
|
| `q` / `Esc` | Close |
|
|
@@ -111,7 +123,11 @@ The "Cache" column combines both read and write tokens.
|
|
|
111
123
|
|
|
112
124
|
## Data Source
|
|
113
125
|
|
|
114
|
-
Statistics are parsed from session files in `~/.pi/agent/sessions
|
|
126
|
+
Statistics are parsed recursively from session files in `~/.pi/agent/sessions/`, including nested subagent runs such as `run-0/` directories. Each session is a JSONL file containing message entries with usage data.
|
|
127
|
+
|
|
128
|
+
In **Deduped** mode, assistant messages duplicated across branched session files are deduplicated by timestamp + total tokens (matching the extension's previous behavior and keeping totals comparable with earlier releases).
|
|
129
|
+
|
|
130
|
+
In **Raw** mode, every assistant message found in every session file is counted.
|
|
115
131
|
|
|
116
132
|
Respects the `PI_CODING_AGENT_DIR` environment variable if set.
|
|
117
133
|
|
package/usage-extension/index.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Shows an inline view with usage stats grouped by provider.
|
|
5
5
|
* - Tab cycles: Today → This Week → All Time
|
|
6
|
+
* - D toggles deduped view, R toggles raw view, M cycles both
|
|
6
7
|
* - Arrow keys navigate providers
|
|
7
8
|
* - Enter expands/collapses to show models
|
|
8
9
|
*/
|
|
@@ -56,6 +57,8 @@ interface UsageData {
|
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
type TabName = "today" | "thisWeek" | "allTime";
|
|
60
|
+
type UsageCountMode = "deduped" | "raw";
|
|
61
|
+
type UsageDataByMode = Record<UsageCountMode, UsageData>;
|
|
59
62
|
|
|
60
63
|
// =============================================================================
|
|
61
64
|
// Column Configuration
|
|
@@ -96,31 +99,27 @@ function getSessionsDir(): string {
|
|
|
96
99
|
return join(agentDir, "sessions");
|
|
97
100
|
}
|
|
98
101
|
|
|
99
|
-
async function
|
|
100
|
-
const sessionsDir = getSessionsDir();
|
|
101
|
-
const files: string[] = [];
|
|
102
|
-
|
|
102
|
+
async function collectSessionFilesRecursively(dir: string, files: string[], signal?: AbortSignal): Promise<void> {
|
|
103
103
|
try {
|
|
104
|
-
const
|
|
105
|
-
for (const
|
|
106
|
-
if (signal?.aborted) return
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if (file.endsWith(".jsonl")) {
|
|
113
|
-
files.push(join(cwdPath, file));
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
} catch {
|
|
117
|
-
// Skip directories we can't read
|
|
104
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
105
|
+
for (const entry of entries) {
|
|
106
|
+
if (signal?.aborted) return;
|
|
107
|
+
const entryPath = join(dir, entry.name);
|
|
108
|
+
if (entry.isDirectory()) {
|
|
109
|
+
await collectSessionFilesRecursively(entryPath, files, signal);
|
|
110
|
+
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
|
111
|
+
files.push(entryPath);
|
|
118
112
|
}
|
|
119
113
|
}
|
|
120
114
|
} catch {
|
|
121
|
-
//
|
|
115
|
+
// Skip directories we can't read
|
|
122
116
|
}
|
|
117
|
+
}
|
|
123
118
|
|
|
119
|
+
async function getAllSessionFiles(signal?: AbortSignal): Promise<string[]> {
|
|
120
|
+
const files: string[] = [];
|
|
121
|
+
await collectSessionFilesRecursively(getSessionsDir(), files, signal);
|
|
122
|
+
files.sort();
|
|
124
123
|
return files;
|
|
125
124
|
}
|
|
126
125
|
|
|
@@ -135,16 +134,23 @@ interface SessionMessage {
|
|
|
135
134
|
timestamp: number;
|
|
136
135
|
}
|
|
137
136
|
|
|
137
|
+
interface ParsedSessionFile {
|
|
138
|
+
sessionId: string;
|
|
139
|
+
rawMessages: SessionMessage[];
|
|
140
|
+
dedupedMessages: SessionMessage[];
|
|
141
|
+
}
|
|
142
|
+
|
|
138
143
|
async function parseSessionFile(
|
|
139
144
|
filePath: string,
|
|
140
145
|
seenHashes: Set<string>,
|
|
141
146
|
signal?: AbortSignal
|
|
142
|
-
): Promise<
|
|
147
|
+
): Promise<ParsedSessionFile | null> {
|
|
143
148
|
try {
|
|
144
149
|
const content = await readFile(filePath, "utf8");
|
|
145
150
|
if (signal?.aborted) return null;
|
|
146
151
|
const lines = content.trim().split("\n");
|
|
147
|
-
const
|
|
152
|
+
const rawMessages: SessionMessage[] = [];
|
|
153
|
+
const dedupedMessages: SessionMessage[] = [];
|
|
148
154
|
let sessionId = "";
|
|
149
155
|
|
|
150
156
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -169,14 +175,7 @@ async function parseSessionFile(
|
|
|
169
175
|
const fallbackTs = entry.timestamp ? new Date(entry.timestamp).getTime() : 0;
|
|
170
176
|
const timestamp = msg.timestamp || (Number.isNaN(fallbackTs) ? 0 : fallbackTs);
|
|
171
177
|
|
|
172
|
-
|
|
173
|
-
// Session files contain many duplicate entries
|
|
174
|
-
const totalTokens = input + output + cacheRead + cacheWrite;
|
|
175
|
-
const hash = `${timestamp}:${totalTokens}`;
|
|
176
|
-
if (seenHashes.has(hash)) continue;
|
|
177
|
-
seenHashes.add(hash);
|
|
178
|
-
|
|
179
|
-
messages.push({
|
|
178
|
+
const sessionMessage: SessionMessage = {
|
|
180
179
|
provider: msg.provider,
|
|
181
180
|
model: msg.model,
|
|
182
181
|
cost: msg.usage.cost?.total || 0,
|
|
@@ -185,7 +184,16 @@ async function parseSessionFile(
|
|
|
185
184
|
cacheRead,
|
|
186
185
|
cacheWrite,
|
|
187
186
|
timestamp,
|
|
188
|
-
}
|
|
187
|
+
};
|
|
188
|
+
rawMessages.push(sessionMessage);
|
|
189
|
+
|
|
190
|
+
// Deduplicate copied history across branched session files.
|
|
191
|
+
// Keep the existing ccusage-style hash so current totals remain comparable.
|
|
192
|
+
const totalTokens = input + output + cacheRead + cacheWrite;
|
|
193
|
+
const hash = `${timestamp}:${totalTokens}`;
|
|
194
|
+
if (seenHashes.has(hash)) continue;
|
|
195
|
+
seenHashes.add(hash);
|
|
196
|
+
dedupedMessages.push(sessionMessage);
|
|
189
197
|
}
|
|
190
198
|
}
|
|
191
199
|
} catch {
|
|
@@ -193,7 +201,7 @@ async function parseSessionFile(
|
|
|
193
201
|
}
|
|
194
202
|
}
|
|
195
203
|
|
|
196
|
-
return sessionId ? { sessionId,
|
|
204
|
+
return sessionId ? { sessionId, rawMessages, dedupedMessages } : null;
|
|
197
205
|
} catch {
|
|
198
206
|
return null;
|
|
199
207
|
}
|
|
@@ -232,7 +240,74 @@ function emptyTimeFilteredStats(): TimeFilteredStats {
|
|
|
232
240
|
};
|
|
233
241
|
}
|
|
234
242
|
|
|
235
|
-
|
|
243
|
+
function emptyUsageData(): UsageData {
|
|
244
|
+
return {
|
|
245
|
+
today: emptyTimeFilteredStats(),
|
|
246
|
+
thisWeek: emptyTimeFilteredStats(),
|
|
247
|
+
allTime: emptyTimeFilteredStats(),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function getPeriodsForTimestamp(timestamp: number, todayMs: number, weekStartMs: number): TabName[] {
|
|
252
|
+
const periods: TabName[] = ["allTime"];
|
|
253
|
+
if (timestamp >= todayMs) periods.push("today");
|
|
254
|
+
if (timestamp >= weekStartMs) periods.push("thisWeek");
|
|
255
|
+
return periods;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function addMessagesToUsageData(
|
|
259
|
+
data: UsageData,
|
|
260
|
+
sessionId: string,
|
|
261
|
+
messages: SessionMessage[],
|
|
262
|
+
todayMs: number,
|
|
263
|
+
weekStartMs: number
|
|
264
|
+
): void {
|
|
265
|
+
const sessionContributed = { today: false, thisWeek: false, allTime: false };
|
|
266
|
+
|
|
267
|
+
for (const msg of messages) {
|
|
268
|
+
const periods = getPeriodsForTimestamp(msg.timestamp, todayMs, weekStartMs);
|
|
269
|
+
const tokens = {
|
|
270
|
+
// Total = input + output only. cacheRead/cacheWrite are tracked separately.
|
|
271
|
+
// cacheRead tokens were already counted when first sent, so including them
|
|
272
|
+
// would double-count and massively inflate totals (cache hits repeat every message).
|
|
273
|
+
total: msg.input + msg.output,
|
|
274
|
+
input: msg.input,
|
|
275
|
+
output: msg.output,
|
|
276
|
+
cache: msg.cacheRead + msg.cacheWrite,
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
for (const period of periods) {
|
|
280
|
+
const stats = data[period];
|
|
281
|
+
|
|
282
|
+
let providerStats = stats.providers.get(msg.provider);
|
|
283
|
+
if (!providerStats) {
|
|
284
|
+
providerStats = emptyProviderStats();
|
|
285
|
+
stats.providers.set(msg.provider, providerStats);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
let modelStats = providerStats.models.get(msg.model);
|
|
289
|
+
if (!modelStats) {
|
|
290
|
+
modelStats = emptyModelStats();
|
|
291
|
+
providerStats.models.set(msg.model, modelStats);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
modelStats.sessions.add(sessionId);
|
|
295
|
+
accumulateStats(modelStats, msg.cost, tokens);
|
|
296
|
+
|
|
297
|
+
providerStats.sessions.add(sessionId);
|
|
298
|
+
accumulateStats(providerStats, msg.cost, tokens);
|
|
299
|
+
|
|
300
|
+
accumulateStats(stats.totals, msg.cost, tokens);
|
|
301
|
+
sessionContributed[period] = true;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (sessionContributed.today) data.today.totals.sessions++;
|
|
306
|
+
if (sessionContributed.thisWeek) data.thisWeek.totals.sessions++;
|
|
307
|
+
if (sessionContributed.allTime) data.allTime.totals.sessions++;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function collectUsageData(signal?: AbortSignal): Promise<UsageDataByMode | null> {
|
|
236
311
|
const startOfToday = new Date();
|
|
237
312
|
startOfToday.setHours(0, 0, 0, 0);
|
|
238
313
|
const todayMs = startOfToday.getTime();
|
|
@@ -245,15 +320,14 @@ async function collectUsageData(signal?: AbortSignal): Promise<UsageData | null>
|
|
|
245
320
|
startOfWeek.setHours(0, 0, 0, 0);
|
|
246
321
|
const weekStartMs = startOfWeek.getTime();
|
|
247
322
|
|
|
248
|
-
const data:
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
allTime: emptyTimeFilteredStats(),
|
|
323
|
+
const data: UsageDataByMode = {
|
|
324
|
+
deduped: emptyUsageData(),
|
|
325
|
+
raw: emptyUsageData(),
|
|
252
326
|
};
|
|
253
327
|
|
|
254
328
|
const sessionFiles = await getAllSessionFiles(signal);
|
|
255
329
|
if (signal?.aborted) return null;
|
|
256
|
-
const seenHashes = new Set<string>();
|
|
330
|
+
const seenHashes = new Set<string>();
|
|
257
331
|
|
|
258
332
|
for (const filePath of sessionFiles) {
|
|
259
333
|
if (signal?.aborted) return null;
|
|
@@ -261,59 +335,8 @@ async function collectUsageData(signal?: AbortSignal): Promise<UsageData | null>
|
|
|
261
335
|
if (signal?.aborted) return null;
|
|
262
336
|
if (!parsed) continue;
|
|
263
337
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
for (const msg of messages) {
|
|
268
|
-
if (signal?.aborted) return null;
|
|
269
|
-
const periods: TabName[] = ["allTime"];
|
|
270
|
-
if (msg.timestamp >= todayMs) periods.push("today");
|
|
271
|
-
if (msg.timestamp >= weekStartMs) periods.push("thisWeek");
|
|
272
|
-
|
|
273
|
-
const tokens = {
|
|
274
|
-
// Total = input + output only. cacheRead/cacheWrite are tracked separately.
|
|
275
|
-
// cacheRead tokens were already counted when first sent, so including them
|
|
276
|
-
// would double-count and massively inflate totals (cache hits repeat every message).
|
|
277
|
-
total: msg.input + msg.output,
|
|
278
|
-
input: msg.input,
|
|
279
|
-
output: msg.output,
|
|
280
|
-
cache: msg.cacheRead + msg.cacheWrite,
|
|
281
|
-
};
|
|
282
|
-
|
|
283
|
-
for (const period of periods) {
|
|
284
|
-
const stats = data[period];
|
|
285
|
-
|
|
286
|
-
// Get or create provider stats
|
|
287
|
-
let providerStats = stats.providers.get(msg.provider);
|
|
288
|
-
if (!providerStats) {
|
|
289
|
-
providerStats = emptyProviderStats();
|
|
290
|
-
stats.providers.set(msg.provider, providerStats);
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
// Get or create model stats
|
|
294
|
-
let modelStats = providerStats.models.get(msg.model);
|
|
295
|
-
if (!modelStats) {
|
|
296
|
-
modelStats = emptyModelStats();
|
|
297
|
-
providerStats.models.set(msg.model, modelStats);
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// Accumulate stats at all levels
|
|
301
|
-
modelStats.sessions.add(sessionId);
|
|
302
|
-
accumulateStats(modelStats, msg.cost, tokens);
|
|
303
|
-
|
|
304
|
-
providerStats.sessions.add(sessionId);
|
|
305
|
-
accumulateStats(providerStats, msg.cost, tokens);
|
|
306
|
-
|
|
307
|
-
accumulateStats(stats.totals, msg.cost, tokens);
|
|
308
|
-
|
|
309
|
-
sessionContributed[period] = true;
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
// Count unique sessions per period
|
|
314
|
-
if (sessionContributed.today) data.today.totals.sessions++;
|
|
315
|
-
if (sessionContributed.thisWeek) data.thisWeek.totals.sessions++;
|
|
316
|
-
if (sessionContributed.allTime) data.allTime.totals.sessions++;
|
|
338
|
+
addMessagesToUsageData(data.raw, parsed.sessionId, parsed.rawMessages, todayMs, weekStartMs);
|
|
339
|
+
addMessagesToUsageData(data.deduped, parsed.sessionId, parsed.dedupedMessages, todayMs, weekStartMs);
|
|
317
340
|
|
|
318
341
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
319
342
|
}
|
|
@@ -372,9 +395,17 @@ const TAB_LABELS: Record<TabName, string> = {
|
|
|
372
395
|
|
|
373
396
|
const TAB_ORDER: TabName[] = ["today", "thisWeek", "allTime"];
|
|
374
397
|
|
|
398
|
+
const MODE_LABELS: Record<UsageCountMode, string> = {
|
|
399
|
+
deduped: "Deduped",
|
|
400
|
+
raw: "Raw",
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
const MODE_ORDER: UsageCountMode[] = ["deduped", "raw"];
|
|
404
|
+
|
|
375
405
|
class UsageComponent {
|
|
376
406
|
private activeTab: TabName = "allTime";
|
|
377
|
-
private
|
|
407
|
+
private activeMode: UsageCountMode = "deduped";
|
|
408
|
+
private data: UsageDataByMode;
|
|
378
409
|
private selectedIndex = 0;
|
|
379
410
|
private expanded = new Set<string>();
|
|
380
411
|
private providerOrder: string[] = [];
|
|
@@ -382,7 +413,7 @@ class UsageComponent {
|
|
|
382
413
|
private requestRender: () => void;
|
|
383
414
|
private done: () => void;
|
|
384
415
|
|
|
385
|
-
constructor(theme: Theme, data:
|
|
416
|
+
constructor(theme: Theme, data: UsageDataByMode, requestRender: () => void, done: () => void) {
|
|
386
417
|
this.theme = theme;
|
|
387
418
|
this.requestRender = requestRender;
|
|
388
419
|
this.done = done;
|
|
@@ -390,14 +421,25 @@ class UsageComponent {
|
|
|
390
421
|
this.updateProviderOrder();
|
|
391
422
|
}
|
|
392
423
|
|
|
424
|
+
private getActiveStats(): TimeFilteredStats {
|
|
425
|
+
return this.data[this.activeMode][this.activeTab];
|
|
426
|
+
}
|
|
427
|
+
|
|
393
428
|
private updateProviderOrder(): void {
|
|
394
|
-
const stats = this.
|
|
429
|
+
const stats = this.getActiveStats();
|
|
395
430
|
this.providerOrder = Array.from(stats.providers.entries())
|
|
396
431
|
.sort((a, b) => b[1].cost - a[1].cost)
|
|
397
432
|
.map(([name]) => name);
|
|
398
433
|
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.providerOrder.length - 1));
|
|
399
434
|
}
|
|
400
435
|
|
|
436
|
+
private cycleMode(step: 1 | -1): void {
|
|
437
|
+
const idx = MODE_ORDER.indexOf(this.activeMode);
|
|
438
|
+
this.activeMode = MODE_ORDER[(idx + step + MODE_ORDER.length) % MODE_ORDER.length]!;
|
|
439
|
+
this.updateProviderOrder();
|
|
440
|
+
this.requestRender();
|
|
441
|
+
}
|
|
442
|
+
|
|
401
443
|
handleInput(data: string): void {
|
|
402
444
|
if (matchesKey(data, "escape") || matchesKey(data, "q")) {
|
|
403
445
|
this.done();
|
|
@@ -414,6 +456,20 @@ class UsageComponent {
|
|
|
414
456
|
this.activeTab = TAB_ORDER[(idx - 1 + TAB_ORDER.length) % TAB_ORDER.length]!;
|
|
415
457
|
this.updateProviderOrder();
|
|
416
458
|
this.requestRender();
|
|
459
|
+
} else if (matchesKey(data, "m")) {
|
|
460
|
+
this.cycleMode(1);
|
|
461
|
+
} else if (matchesKey(data, "d")) {
|
|
462
|
+
if (this.activeMode !== "deduped") {
|
|
463
|
+
this.activeMode = "deduped";
|
|
464
|
+
this.updateProviderOrder();
|
|
465
|
+
this.requestRender();
|
|
466
|
+
}
|
|
467
|
+
} else if (matchesKey(data, "r")) {
|
|
468
|
+
if (this.activeMode !== "raw") {
|
|
469
|
+
this.activeMode = "raw";
|
|
470
|
+
this.updateProviderOrder();
|
|
471
|
+
this.requestRender();
|
|
472
|
+
}
|
|
417
473
|
} else if (matchesKey(data, "up")) {
|
|
418
474
|
if (this.selectedIndex > 0) {
|
|
419
475
|
this.selectedIndex--;
|
|
@@ -445,6 +501,7 @@ class UsageComponent {
|
|
|
445
501
|
return [
|
|
446
502
|
...this.renderTitle(),
|
|
447
503
|
...this.renderTabs(),
|
|
504
|
+
...this.renderModes(),
|
|
448
505
|
...this.renderHeader(),
|
|
449
506
|
...this.renderRows(),
|
|
450
507
|
...this.renderTotals(),
|
|
@@ -463,7 +520,19 @@ class UsageComponent {
|
|
|
463
520
|
const label = TAB_LABELS[tab];
|
|
464
521
|
return tab === this.activeTab ? th.fg("accent", `[${label}]`) : th.fg("dim", ` ${label} `);
|
|
465
522
|
}).join(" ");
|
|
466
|
-
return [tabs
|
|
523
|
+
return [tabs];
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
private renderModes(): string[] {
|
|
527
|
+
const th = this.theme;
|
|
528
|
+
const modes = MODE_ORDER.map((mode) => {
|
|
529
|
+
const label = MODE_LABELS[mode];
|
|
530
|
+
return mode === this.activeMode ? th.fg("accent", `[${label}]`) : th.fg("dim", ` ${label} `);
|
|
531
|
+
}).join(" ");
|
|
532
|
+
const note = this.activeMode === "deduped"
|
|
533
|
+
? "Dedupes copied branched-history messages. Recursive subagent sessions included."
|
|
534
|
+
: "Counts raw message totals from all session files. Recursive subagent sessions included.";
|
|
535
|
+
return [modes, th.fg("dim", note), ""];
|
|
467
536
|
}
|
|
468
537
|
|
|
469
538
|
private renderHeader(): string[] {
|
|
@@ -504,7 +573,7 @@ class UsageComponent {
|
|
|
504
573
|
|
|
505
574
|
private renderRows(): string[] {
|
|
506
575
|
const th = this.theme;
|
|
507
|
-
const stats = this.
|
|
576
|
+
const stats = this.getActiveStats();
|
|
508
577
|
const lines: string[] = [];
|
|
509
578
|
|
|
510
579
|
if (this.providerOrder.length === 0) {
|
|
@@ -542,7 +611,7 @@ class UsageComponent {
|
|
|
542
611
|
|
|
543
612
|
private renderTotals(): string[] {
|
|
544
613
|
const th = this.theme;
|
|
545
|
-
const stats = this.
|
|
614
|
+
const stats = this.getActiveStats();
|
|
546
615
|
|
|
547
616
|
let totalRow = padRight(th.bold("Total"), NAME_COL_WIDTH);
|
|
548
617
|
for (const col of DATA_COLUMNS) {
|
|
@@ -554,7 +623,7 @@ class UsageComponent {
|
|
|
554
623
|
}
|
|
555
624
|
|
|
556
625
|
private renderHelp(): string[] {
|
|
557
|
-
return [this.theme.fg("dim", "[Tab/←→] period [↑↓] select [Enter] expand [q] close")];
|
|
626
|
+
return [this.theme.fg("dim", "[Tab/←→] period [m/d/r] count mode [↑↓] select [Enter] expand [q] close")];
|
|
558
627
|
}
|
|
559
628
|
|
|
560
629
|
invalidate(): void {}
|
|
@@ -573,7 +642,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
573
642
|
return;
|
|
574
643
|
}
|
|
575
644
|
|
|
576
|
-
const data = await ctx.ui.custom<
|
|
645
|
+
const data = await ctx.ui.custom<UsageDataByMode | null>((tui, theme, _kb, done) => {
|
|
577
646
|
const loader = new CancellableLoader(
|
|
578
647
|
tui,
|
|
579
648
|
(s: string) => theme.fg("accent", s),
|
|
@@ -581,7 +650,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
581
650
|
"Loading Usage..."
|
|
582
651
|
);
|
|
583
652
|
let finished = false;
|
|
584
|
-
const finish = (value:
|
|
653
|
+
const finish = (value: UsageDataByMode | null) => {
|
|
585
654
|
if (finished) return;
|
|
586
655
|
finished = true;
|
|
587
656
|
loader.dispose();
|