diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 1dcd141..2d83876 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -17,13 +17,13 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 with: - version: 11.3.0 + version: 11.8.0 run_install: false - name: Setup Node uses: actions/setup-node@v4 with: - node-version: "22" + node-version: "24" cache: pnpm - name: Install diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 579ec0e..2902b49 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,13 +18,13 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 with: - version: 11.3.0 + version: 11.8.0 run_install: false - name: Setup Node uses: actions/setup-node@v4 with: - node-version: "22" + node-version: "24" cache: pnpm registry-url: "https://registry.npmjs.org" diff --git a/CHANGELOG.md b/CHANGELOG.md index a5066c6..ac0f002 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ All notable changes to `@pi-vault/pi-usage` are documented here. +## v0.5.0 + +- added `usage.json` provider toggles so you can disable live providers you do not want queried +- added dashboard insights for project, active skill, and MCP server breakdowns +- grouped insight rows by category and cap each insight section with an overflow summary to keep the dashboard readable +- improved offline session parsing to extract project names, active skills, MCP server names, and builtin tool usage more accurately +- fixed dashboard readability around usage statistics spacing and grouped insight rendering +- updated runtime requirements and tooling to Node `>=24.15.0`, refreshed dependencies, and aligned CI with Node 24 and pnpm 11.8.0 + ## v0.4.0 - reorganized the extension around a dedicated `UsageCore`, with the package entrypoint reduced to a thin Pi adapter diff --git a/README.md b/README.md index 3b3d660..22ab9a1 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,10 @@ [![npm version](https://img.shields.io/npm/v/%40pi-vault%2Fpi-usage)](https://www.npmjs.com/package/@pi-vault/pi-usage) [![Quality](https://github.com/pi-vault/pi-usage/actions/workflows/quality.yml/badge.svg?branch=master)](https://github.com/pi-vault/pi-usage/actions/workflows/quality.yml) -[![Node >= 22.19](https://img.shields.io/badge/node-%3E%3D22.19-339933?logo=node.js&logoColor=white)](https://nodejs.org/) +[![Node >= 24.15.0](https://img.shields.io/badge/node-%3E%3D24.15.0-339933?logo=node.js&logoColor=white)](https://nodejs.org/) [![License: MIT](https://img.shields.io/badge/license-MIT-yellow.svg)](LICENSE) -Track Pi usage across your sessions in one dashboard. `@pi-vault/pi-usage` combines offline history with live provider snapshots so you can see costs, tokens, session activity, and rolling quota status without leaving Pi. +Track Pi usage across your sessions in one dashboard. `@pi-vault/pi-usage` combines offline history with live provider snapshots so you can review costs, tokens, session activity, current quotas, and usage insights without leaving Pi. ![Pi usage dashboard showing the aggregated usage table for the "All Time" period, current usage quota bars for OpenAI/Codex, and the keyboard navigation hints at the bottom](docs/assets/dashboard-ui.png) @@ -26,11 +26,11 @@ Then reload Pi: - `/usage` opens the dashboard using cached live data when available. Use it for quick inspection. - `/usage:refresh` forces a live refresh, rescans local history, and then opens the dashboard. -## What You Get +## What the dashboard shows ### Usage statistics -The top section aggregates all local Pi session history for the selected period. +The top section aggregates local Pi session history for the selected period. - switch between `Today`, `This Week`, `Last Week`, and `All Time` - expand provider rows to inspect model-level usage @@ -39,12 +39,22 @@ The top section aggregates all local Pi session history for the selected period. ### Current usage -The lower section shows live quota and balance information for providers you have already configured in Pi. +The lower section shows the supported providers. Configured providers can return live quota and balance data, while unconfigured ones may show `unavailable` or local fallback states. - switch between `OpenAI/Codex`, `MiniMax`, `StepFun`, `OpenCode Go`, `Command Code`, and `OpenRouter` - view rolling-window quota bars like `5h` and weekly usage - see balance-style fields where the provider exposes them -- get inline status for live, cached, stale, or fallback data +- get inline status for live, cached, stale, local, or unavailable data + +### Insights + +Press `v` to toggle insights for the selected period. + +- review the most expensive projects in your local session history +- see active skill and MCP server breakdowns when that data is present +- keep long sections readable through grouped insight categories and capped lists with overflow summaries + +## How to use it ### Keyboard shortcuts @@ -55,11 +65,54 @@ The lower section shows live quota and balance information for providers you hav - `[v]` toggle insights - `[q/Esc]` close the dashboard -## Provider Setup +## Configuration + +### `usage.json` + +Create `$PI_CODING_AGENT_DIR/extensions/usage.json` to disable specific live providers. + +Default behavior: +- if the file is missing, all providers stay enabled +- if the file is `{}`, all providers stay enabled +- if a provider is omitted, that provider stays enabled +- if the JSON is malformed, `@pi-vault/pi-usage` ignores it and falls back to the default behavior + +Default example: + +```json +{} +``` + +Explicit all-providers-enabled example: + +```json +{ + "providers": { + "openai-codex": { "enabled": true }, + "minimax": { "enabled": true }, + "stepfun": { "enabled": true }, + "opencode-go": { "enabled": true }, + "command-code": { "enabled": true }, + "openrouter": { "enabled": true } + } +} +``` -Offline history works without extra setup. Live provider cards appear only for providers you have already configured. +Disable MiniMax only: + +```json +{ + "providers": { + "minimax": { "enabled": false } + } +} +``` -### OpenAI/Codex +### Provider setup + +Offline history works without extra setup. Provider cards appear for every supported live provider unless you disable them in `usage.json`. Providers you configure can return live data; others may show `unavailable` or local fallback states. + +#### OpenAI/Codex Pi usage can reuse existing Pi or Codex auth. Optional overrides: @@ -70,7 +123,7 @@ Pi usage can reuse existing Pi or Codex auth. Optional overrides: - `OPENAI_CODEX_ACCOUNT_ID` - `CHATGPT_ACCOUNT_ID` -### MiniMax +#### MiniMax Set one of: @@ -81,14 +134,14 @@ Optional override: - `MINIMAX_API_HOST` -### StepFun +#### StepFun Set one of: - `STEPFUN_TOKEN` - `STEPFUN_USERNAME` and `STEPFUN_PASSWORD` -### OpenCode Go +#### OpenCode Go Set: @@ -97,13 +150,13 @@ Set: `OPENCODE_GO_WORKSPACE_ID` accepts either the raw `wrk_...` id or the full workspace URL. -### Command Code +#### Command Code Set: - `COMMAND_CODE_COOKIE_HEADER` -### OpenRouter +#### OpenRouter Set: @@ -115,14 +168,10 @@ Optional overrides: - `OPENROUTER_X_TITLE` - `OPENROUTER_HTTP_REFERER` -## Development +## Changelog -```bash -pnpm install -pnpm check -pnpm pack --dry-run -``` +See [`CHANGELOG.md`](CHANGELOG.md) for release-by-release notes. ## License -MIT +MIT — see [`LICENSE`](LICENSE). diff --git a/biome.json b/biome.json index 3a3b95c..a81c026 100644 --- a/biome.json +++ b/biome.json @@ -1,10 +1,13 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.16/schema.json", - "linter": { + "$schema": "https://biomejs.dev/schemas/2.5.0/schema.json", + "vcs": { "enabled": true, - "rules": { - "recommended": true - } + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": false, + "includes": ["src/**/*.ts", "tests/**/*.ts", "!**/node_modules"] }, "formatter": { "enabled": true, @@ -12,13 +15,24 @@ "indentWidth": 2, "lineWidth": 100 }, + "linter": { + "enabled": true, + "rules": { + "preset": "recommended" + } + }, "javascript": { "formatter": { "quoteStyle": "double", "semicolons": "always" } }, - "files": { - "includes": ["src/**/*.ts", "tests/**/*.ts", "!**/node_modules"] + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } } } diff --git a/docs/superpowers/plans/2026-06-21-dashboard-enhancements-plan.md b/docs/superpowers/plans/2026-06-21-dashboard-enhancements-plan.md deleted file mode 100644 index e66ee89..0000000 --- a/docs/superpowers/plans/2026-06-21-dashboard-enhancements-plan.md +++ /dev/null @@ -1,1405 +0,0 @@ -# Dashboard Enhancements Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add provider enable/disable config, richer insights (project/skill/MCP breakdowns), and a spacing fix to the pi-usage dashboard. - -**Architecture:** Four independent phases, each producing a usable result. Phase 1 is a one-line spacing fix. Phase 2 adds config-driven provider toggling. Phases 3-4 enrich the offline scan and dashboard insights view with project, skill, and MCP server breakdowns. - -**Tech Stack:** TypeScript, Vitest, Pi extension API (`@earendil-works/pi-coding-agent`), TUI rendering - -**Spec:** `docs/superpowers/specs/2026-06-21-dashboard-enhancements-design.md` - ---- - -## File Map - -| File | Action | Responsibility | -| -------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -| `src/tui/dashboard.ts` | Modify | Phase 1: spacing fix; Phase 3: category-grouped insights rendering | -| `src/shared/types.ts` | Modify | Phase 2: `UsageConfig` type; Phase 3: `category` field on `UsageInsight` | -| `src/core/usage-core.ts` | Modify | Phase 2: load config, filter disabled providers | -| `src/core/offline.ts` | Modify | Phase 3: extract CWD, enrich `UsageTurn`; Phase 3: project insights in `buildInsights()`; Phase 4: extract skills + MCP tools, add skill/MCP insights | -| `tests/dashboard.test.ts` | Modify | Phase 1: adjust for spacing; Phase 3: insights rendering tests | -| `tests/offline.test.ts` | Modify | Phase 3: project extraction + project insights tests; Phase 4: skill + MCP extraction + insights tests | -| `tests/usage-core.test.ts` | Modify | Phase 2: config loading + provider filtering tests | - ---- - -## Phase 1: Spacing Fix - -### Task 1.1: Add empty line between "Usage Statistics" title and period tabs - -**Files:** - -- Modify: `src/tui/dashboard.ts:364-377` -- Modify: `tests/dashboard.test.ts` (affected rendering assertions) - -- [ ] **Step 1: Update the rendering method** - -In `src/tui/dashboard.ts`, method `renderUsageStatistics()`, add an empty line between the section title and the tabs: - -```ts -// Find this block (lines 364-377): -private renderUsageStatistics(w: number, lines: string[]): void { - lines.push( - this.sectionTitle( - UI_STRINGS.dashboardBorderedSectionTitles.usageStatistics, - ), - ); - - lines.push( - ...this.renderTabs( - PERIODS.map((period) => PERIOD_LABELS[period]), - this.periodIndex, - w, - ), - ); - -// Replace with: -private renderUsageStatistics(w: number, lines: string[]): void { - lines.push( - this.sectionTitle( - UI_STRINGS.dashboardBorderedSectionTitles.usageStatistics, - ), - ); - lines.push(""); - - lines.push( - ...this.renderTabs( - PERIODS.map((period) => PERIOD_LABELS[period]), - this.periodIndex, - w, - ), - ); -``` - -- [ ] **Step 2: Run tests to check what breaks** - -Run: `pnpm test -- tests/dashboard.test.ts` - -Some render snapshot assertions may fail because they expect the old line ordering. Note which tests fail. - -- [ ] **Step 3: Fix any failing dashboard tests** - -If tests assert exact line positions or patterns that now shift by one line due to the empty line, update those assertions. The empty line appears between the "Usage Statistics" title and the `[All Time]` tab line in the render output. - -- [ ] **Step 4: Run full check** - -Run: `pnpm check` - -Expected: all lint, typecheck, and tests pass. - -- [ ] **Step 5: Commit** - -```bash -git add src/tui/dashboard.ts tests/dashboard.test.ts -git commit -m "fix(tui): add spacing between Usage Statistics title and period tabs" -``` - ---- - -## Phase 2: Provider Enable/Disable Toggle - -### Task 2.1: Add UsageConfig type - -**Files:** - -- Modify: `src/shared/types.ts` - -- [ ] **Step 1: Add the UsageConfig interface** - -Append after the `UsageDashboardState` interface (around line 136) in `src/shared/types.ts`: - -```ts -export interface UsageConfig { - providers?: Partial>; -} -``` - -- [ ] **Step 2: Run typecheck** - -Run: `pnpm typecheck` - -Expected: PASS (new type, no consumers yet). - -- [ ] **Step 3: Commit** - -```bash -git add src/shared/types.ts -git commit -m "feat(types): add UsageConfig interface for provider toggle" -``` - -### Task 2.2: Implement loadConfig and provider filtering - -**Files:** - -- Modify: `src/core/usage-core.ts` -- Modify: `tests/usage-core.test.ts` - -- [ ] **Step 1: Write failing tests for config loading** - -Add a new `describe("config loading", ...)` block at the end of `tests/usage-core.test.ts`: - -```ts -import { join } from "node:path"; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; - -// Add this describe block after the existing test blocks: - -describe("config loading", () => { - function mkTmp(): string { - return mkdtempSync(join(tmpdir(), "pi-usage-config-")); - } - - it("loads config and filters disabled providers", async () => { - const root = mkTmp(); - const extDir = join(root, "extensions"); - mkdirSync(extDir, { recursive: true }); - writeFileSync( - join(extDir, "usage.json"), - JSON.stringify({ providers: { minimax: { enabled: false } } }), - ); - // Create a session file so bootstrap has something to scan - const sessions = join(root, "sessions"); - mkdirSync(sessions, { recursive: true }); - writeFileSync(join(sessions, "s.jsonl"), "", "utf8"); - - const core = createUsageCore({ - deps: createTestDeps(root), - onEmit: () => {}, - }); - await core.bootstrap(); - const state = core.getState(); - - // minimax should be excluded from providers - expect( - state.providers.find((p) => p.providerId === "minimax"), - ).toBeUndefined(); - // other providers should still be present - expect( - state.providers.find((p) => p.providerId === "openai-codex"), - ).toBeDefined(); - - rmSync(root, { recursive: true, force: true }); - }); - - it("treats missing config file as all providers enabled", async () => { - const root = mkTmp(); - const sessions = join(root, "sessions"); - mkdirSync(sessions, { recursive: true }); - writeFileSync(join(sessions, "s.jsonl"), "", "utf8"); - - const core = createUsageCore({ - deps: createTestDeps(root), - onEmit: () => {}, - }); - await core.bootstrap(); - const state = core.getState(); - - // All live providers should be present (though unavailable without credentials) - const ids = state.providers.map((p) => p.providerId); - expect(ids).toContain("openai-codex"); - expect(ids).toContain("minimax"); - - rmSync(root, { recursive: true, force: true }); - }); - - it("ignores malformed config JSON", async () => { - const root = mkTmp(); - const extDir = join(root, "extensions"); - mkdirSync(extDir, { recursive: true }); - writeFileSync(join(extDir, "usage.json"), "not json!!!"); - const sessions = join(root, "sessions"); - mkdirSync(sessions, { recursive: true }); - writeFileSync(join(sessions, "s.jsonl"), "", "utf8"); - - const core = createUsageCore({ - deps: createTestDeps(root), - onEmit: () => {}, - }); - await core.bootstrap(); - const state = core.getState(); - - // Should not crash, all providers present - const ids = state.providers.map((p) => p.providerId); - expect(ids).toContain("minimax"); - - rmSync(root, { recursive: true, force: true }); - }); -}); -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `pnpm test -- tests/usage-core.test.ts` - -Expected: FAIL — `createTestDeps` may not exist in that file (check the existing helper name; it may be `createTestDeps` or inline). Also, the config loading logic doesn't exist yet so the disabled-provider test should fail. - -- [ ] **Step 3: Implement loadConfig and filtering in usage-core.ts** - -In `src/core/usage-core.ts`, add the `loadConfig` function and update `createUsageCore`: - -```ts -// Add import at the top: -import { join } from "node:path"; -import type { UsageConfig } from "../shared/types.ts"; - -// Add this function before createUsageCore: -async function loadConfig(deps: UsageDeps): Promise { - try { - const configPath = join(deps.agentDir(), "extensions", "usage.json"); - const raw = await deps.readFile(configPath, "utf8"); - return JSON.parse(raw as string) as UsageConfig; - } catch { - return {}; - } -} -``` - -Then change `createUsageCore` to accept config and filter providers. The cleanest approach: make `createUsageCore` async or add an `init()` step. Since `bootstrap()` is already async and called before anything else, load config there and filter before the first fetch. - -Update the function: - -```ts -export function createUsageCore(options: UsageCoreOptions): UsageCore { - const { deps, onEmit } = options; - - // --- Provider registry (created once, filtered after config load) --- - let providers = createProviderRegistry(deps); - let liveProviderIds = new Set( - providers.filter((p) => p.strategy === "api").map((p) => p.id), - ); - let liveProviderSnapshotFiles = new Set( - [...liveProviderIds].map((id) => `${id}.json`), - ); - - // ... (rest of state and internal variables unchanged) ... - - async function bootstrap(): Promise { - const config = await loadConfig(deps); - if (config.providers) { - providers = providers.filter((p) => { - const setting = config.providers?.[p.id]; - return setting?.enabled !== false; - }); - liveProviderIds = new Set( - providers.filter((p) => p.strategy === "api").map((p) => p.id), - ); - liveProviderSnapshotFiles = new Set( - [...liveProviderIds].map((id) => `${id}.json`), - ); - } - await Promise.all([populateProviders(false), refreshOffline(false)]); - state.diagnostics = ["live runtime ready"]; - emit(USAGE_CORE_READY_EVENT); - } - - // ... rest unchanged ... -} -``` - -Note: change `const providers` to `let providers`, `const liveProviderIds` to `let liveProviderIds`, `const liveProviderSnapshotFiles` to `let liveProviderSnapshotFiles`. - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `pnpm test -- tests/usage-core.test.ts` - -Expected: PASS — all existing tests plus the new config tests. - -- [ ] **Step 5: Run full check** - -Run: `pnpm check` - -Expected: all lint, typecheck, and tests pass. - -- [ ] **Step 6: Commit** - -```bash -git add src/core/usage-core.ts tests/usage-core.test.ts -git commit -m "feat(core): add provider enable/disable toggle via extensions/usage.json" -``` - ---- - -## Phase 3: Insight Infrastructure + Project Breakdown - -### Task 3.1: Add category field to InsightItem - -**Files:** - -- Modify: `src/core/offline.ts` -- Modify: `src/shared/types.ts` - -- [ ] **Step 1: Add category to InsightItem in offline.ts** - -In `src/core/offline.ts`, update the `InsightItem` interface (around line 287): - -```ts -export interface InsightItem { - category?: string; - label: string; - cost: number; - detail: string; -} -``` - -- [ ] **Step 2: Add category to UsageInsight in types.ts** - -In `src/shared/types.ts`, update the `UsageInsight` interface (around line 108): - -```ts -export interface UsageInsight { - category?: string; - label: string; - cost: number; - detail: string; -} -``` - -- [ ] **Step 3: Tag existing insights with category "cost"** - -In `src/core/offline.ts`, in `buildInsights()`, add `category: "cost"` to each of the five existing insight items in the return array (around line 344): - -```ts -return [ - { - category: "cost", - label: "Parallel sessions", - cost: parallelCost, - detail: `${pct(parallelCost)} cost while >=4 active`, - }, - { - category: "cost", - label: "Large context", - cost: largeContext, - detail: `${pct(largeContext)} over 150k context`, - }, - { - category: "cost", - label: "Large uncached", - cost: largeUncached, - detail: `${pct(largeUncached)} over 100k input`, - }, - { - category: "cost", - label: "Long sessions", - cost: longSessionCost, - detail: `${pct(longSessionCost)} from 8h+ sessions`, - }, - { - category: "cost", - label: "Top-5 concentration", - cost: top5, - detail: `${pct(top5)} in top 5 sessions`, - }, -]; -``` - -- [ ] **Step 4: Run tests** - -Run: `pnpm check` - -Expected: PASS — category is optional, existing tests don't assert on it. - -- [ ] **Step 5: Commit** - -```bash -git add src/core/offline.ts src/shared/types.ts -git commit -m "feat(types): add category field to InsightItem and UsageInsight" -``` - -### Task 3.2: Extract session CWD and add project field to UsageTurn - -**Files:** - -- Modify: `src/core/offline.ts` -- Modify: `tests/offline.test.ts` - -- [ ] **Step 1: Write failing test for project extraction** - -Add to the `describe("offline scanner", ...)` block in `tests/offline.test.ts`: - -```ts -it("extracts project name from session header cwd", async () => { - const root = mkTmp(); - const sessions = join(root, "sessions", "proj"); - mkdirSync(sessions, { recursive: true }); - const sessionHeader = JSON.stringify({ - type: "session", - version: 3, - id: "test-session", - timestamp: "2026-05-30T10:00:00Z", - cwd: "/Users/dev/career-ops", - }); - const message = JSON.stringify({ - type: "message", - id: "m1", - timestamp: "2026-05-30T11:00:00Z", - message: { - role: "assistant", - provider: "minimax", - model: "MiniMax-M2.7", - usage: { input: 10, output: 20, cacheRead: 0, cacheWrite: 0, cost: 0.5 }, - }, - }); - writeFileSync( - join(sessions, "s.jsonl"), - `${sessionHeader}\n${message}\n`, - "utf8", - ); - const result = await scanOfflineUsage({ - ...createDefaultDeps(), - agentDir: () => root, - now: () => Date.parse("2026-05-30T12:00:00Z"), - }); - expect(result.turns).toHaveLength(1); - expect(result.turns[0].project).toBe("career-ops"); - rmSync(root, { recursive: true, force: true }); -}); - -it("falls back to undefined project when no session header", async () => { - const root = mkTmp(); - const sessions = join(root, "sessions"); - mkdirSync(sessions, { recursive: true }); - const message = JSON.stringify({ - type: "message", - id: "m1", - timestamp: "2026-05-30T11:00:00Z", - message: { - role: "assistant", - provider: "minimax", - model: "m", - usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, cost: 0.1 }, - }, - }); - writeFileSync(join(sessions, "s.jsonl"), `${message}\n`, "utf8"); - const result = await scanOfflineUsage({ - ...createDefaultDeps(), - agentDir: () => root, - }); - expect(result.turns[0].project).toBeUndefined(); - rmSync(root, { recursive: true, force: true }); -}); -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `pnpm test -- tests/offline.test.ts` - -Expected: FAIL — `project` property doesn't exist on `UsageTurn` yet. - -- [ ] **Step 3: Implement CWD extraction** - -In `src/core/offline.ts`: - -1. Add `project` to the `UsageTurn` interface: - -```ts -export interface UsageTurn { - id: string; - sessionId: string; - timestamp: number; - provider: string; - model: string; - input: number; - output: number; - cacheRead: number; - cacheWrite: number; - tokens: number; - cost: number; - project?: string; -} -``` - -2. Add a helper to extract project from CWD: - -```ts -function projectFromCwd(cwd: unknown): string | undefined { - if (typeof cwd !== "string" || !cwd) return undefined; - const segments = cwd.replace(/\/+$/, "").split("/"); - return segments[segments.length - 1] || undefined; -} -``` - -3. In `scanOfflineUsage()`, inside the file-reading loop (around line 234 where each JSONL file is processed line by line), track the session's CWD. Currently the code reads lines from each file. Add session-level state tracking: - -```ts -// Inside the per-file loop, before iterating lines: -let sessionProject: string | undefined; - -// When parsing each line, before the existing parseLine call: -// Check for session header to extract CWD -try { - const parsed = JSON.parse(line) as Record; - if (parsed?.type === "session" && parsed.cwd) { - sessionProject = projectFromCwd(parsed.cwd); - continue; // session headers are not turns - } -} catch { - // fall through to existing parseLine logic -} -``` - -4. After `parseLine()` returns a turn, set `turn.project = sessionProject`: - -```ts -const turn = parseLine(line, sessionId); -if (!turn) continue; -turn.project = sessionProject; -``` - -Note: The exact location depends on the current loop structure. The key change is: parse `type: "session"` entries to extract `cwd`, store as session-level state, and apply to each turn in that file. - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `pnpm test -- tests/offline.test.ts` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add src/core/offline.ts tests/offline.test.ts -git commit -m "feat(offline): extract project name from session CWD" -``` - -### Task 3.3: Add project insights to buildInsights - -**Files:** - -- Modify: `src/core/offline.ts` -- Modify: `tests/offline.test.ts` - -- [ ] **Step 1: Write failing test for project insights** - -Add to the `describe("insights", ...)` block in `tests/offline.test.ts`: - -```ts -it("produces project breakdown insights", () => { - const turns = [ - { - id: "1", - sessionId: "s1", - timestamp: 1, - provider: "p", - model: "m", - input: 10, - output: 10, - cacheRead: 0, - cacheWrite: 0, - tokens: 20, - cost: 9, - project: "career-ops", - }, - { - id: "2", - sessionId: "s2", - timestamp: 2, - provider: "p", - model: "m", - input: 10, - output: 10, - cacheRead: 0, - cacheWrite: 0, - tokens: 20, - cost: 1, - project: "dotfiles", - }, - ]; - const insights = buildInsights(turns); - const projectInsights = insights.filter((i) => i.category === "project"); - expect(projectInsights.length).toBeGreaterThanOrEqual(2); - expect(projectInsights[0].label).toBe("career-ops"); - expect(projectInsights[0].detail).toContain("90.0%"); - expect(projectInsights[1].label).toBe("dotfiles"); -}); - -it("omits project insights when no projects are set", () => { - const turns = [ - { - id: "1", - sessionId: "s1", - timestamp: 1, - provider: "p", - model: "m", - input: 10, - output: 10, - cacheRead: 0, - cacheWrite: 0, - tokens: 20, - cost: 1, - }, - ]; - const insights = buildInsights(turns); - const projectInsights = insights.filter((i) => i.category === "project"); - expect(projectInsights).toHaveLength(0); -}); -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `pnpm test -- tests/offline.test.ts` - -Expected: FAIL — no insights with `category: "project"` produced yet. - -- [ ] **Step 3: Implement project insights in buildInsights** - -In `src/core/offline.ts`, in the `buildInsights()` function, add project grouping before the return statement: - -```ts -// After the existing top5 calculation, before the return: -const byProject = new Map(); -for (const t of turns) { - if (t.project) { - byProject.set(t.project, (byProject.get(t.project) ?? 0) + t.cost); - } -} -const projectInsights: InsightItem[] = [...byProject.entries()] - .sort((a, b) => b[1] - a[1]) - .map(([project, cost]) => ({ - category: "project", - label: project, - cost, - detail: pct(cost), - })); - -// Update the return to prepend project insights: -return [ - ...projectInsights, - // ... existing 5 cost insights (with category: "cost") ... -]; -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `pnpm test -- tests/offline.test.ts` - -Expected: PASS. - -- [ ] **Step 5: Update existing insight count test** - -The existing test `"produces five insight rows"` asserts `toHaveLength(5)`. Since those turns have no `project` set, it should still be 5. Verify this by running: - -Run: `pnpm test -- tests/offline.test.ts` - -If the test still passes, no change needed. If it fails because the test turns now produce project insights (unlikely since they have no `project` field), update the assertion. - -- [ ] **Step 6: Commit** - -```bash -git add src/core/offline.ts tests/offline.test.ts -git commit -m "feat(insights): add project breakdown by cost" -``` - -### Task 3.4: Category-grouped insights rendering in dashboard - -**Files:** - -- Modify: `src/tui/dashboard.ts` -- Modify: `tests/dashboard.test.ts` - -- [ ] **Step 1: Write failing test for category-grouped rendering** - -Add a new test in the `describe("dashboard rendering", ...)` block in `tests/dashboard.test.ts`: - -```ts -it("renders insights grouped by category", () => { - const state = mkState(); - state.insights = [ - { category: "project", label: "career-ops", cost: 9, detail: "90.0%" }, - { category: "project", label: "dotfiles", cost: 1, detail: "10.0%" }, - { - category: "cost", - label: "Large context", - cost: 5, - detail: "50.0% over 150k context", - }, - ]; - const c = new UsageDashboardComponent(state, () => undefined, { - theme: noTheme, - }); - c.handleInput("v"); - const out = c.render(100).join("\n"); - expect(out).toContain("Projects"); - expect(out).toContain("career-ops"); - expect(out).toContain("90.0%"); - expect(out).toContain("Cost patterns"); - expect(out).toContain("Large context"); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `pnpm test -- tests/dashboard.test.ts` - -Expected: FAIL — "Projects" category header not rendered. - -- [ ] **Step 3: Implement category-grouped rendering** - -In `src/tui/dashboard.ts`, update the insights rendering block inside `renderUsageStatistics()` (around lines 383-398). Replace the flat list rendering: - -```ts -// Find this block: -if (this.showInsights) { - lines.push( - this.sectionTitle(UI_STRINGS.dashboardBorderedSectionTitles.insights), - ); - if (this.state.insights.length === 0) { - lines.push(this.theme.dim("No insights yet.")); - } else { - for (const item of this.state.insights) { - lines.push( - this.theme.dim( - `- ${item.label}: ${formatCurrency(item.cost)} (${item.detail})`, - ), - ); - } - } - return; -} - -// Replace with: -if (this.showInsights) { - lines.push( - this.sectionTitle(UI_STRINGS.dashboardBorderedSectionTitles.insights), - ); - if (this.state.insights.length === 0) { - lines.push(this.theme.dim("No insights yet.")); - } else { - lines.push(...this.renderInsightsByCategory(w)); - } - return; -} -``` - -Then add the `renderInsightsByCategory` method to the class: - -```ts -private renderInsightsByCategory(w: number): string[] { - const lines: string[] = []; - const categoryOrder = ["project", "skill", "mcp", "cost"]; - const categoryLabels: Record = { - project: "Projects", - skill: "Skills", - mcp: "MCP servers", - cost: "Cost patterns", - }; - - // Group insights by category - const grouped = new Map(); - for (const item of this.state.insights) { - const cat = item.category ?? "cost"; - const list = grouped.get(cat) ?? []; - list.push(item); - grouped.set(cat, list); - } - - for (const cat of categoryOrder) { - const items = grouped.get(cat); - if (!items || items.length === 0) continue; - lines.push(""); - const header = categoryLabels[cat] ?? cat; - if (cat === "cost") { - lines.push(this.theme.dim(header)); - for (const item of items) { - lines.push( - this.theme.dim( - ` - ${item.label}: ${formatCurrency(item.cost)} (${item.detail})`, - ), - ); - } - } else { - const pctHeader = "% of usage"; - const maxLabelLen = Math.max(...items.map((i) => i.label.length), header.length); - const headerLine = ` ${padVisible(this.theme.dim(header), maxLabelLen + 2, "left")} ${this.theme.dim(pctHeader)}`; - lines.push(headerLine); - for (const item of items) { - const label = padVisible(this.theme.dim(item.label), maxLabelLen + 2, "left"); - lines.push(` ${label} ${this.theme.dim(item.detail)}`); - } - } - } - - return lines; -} -``` - -Import `padVisible` is already imported at the top of the file. - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `pnpm test -- tests/dashboard.test.ts` - -Expected: PASS. - -- [ ] **Step 5: Verify existing insight toggle test still works** - -The test `"uses enter/space for expand, v for insights"` checks `toContain("Insights")`. The `mkState()` factory has `insights: [{ label: "x", cost: 1, detail: "y" }]` which has no category (defaults to `"cost"`). Verify this test still passes — it should since uncategorized items fall into "Cost patterns". - -- [ ] **Step 6: Run full check** - -Run: `pnpm check` - -Expected: all lint, typecheck, and tests pass. - -- [ ] **Step 7: Commit** - -```bash -git add src/tui/dashboard.ts tests/dashboard.test.ts -git commit -m "feat(tui): render insights grouped by category" -``` - ---- - -## Phase 4: Skill and MCP Server Breakdowns - -### Task 4.1: Extract skill invocations from user messages - -**Files:** - -- Modify: `src/core/offline.ts` -- Modify: `tests/offline.test.ts` - -- [ ] **Step 1: Write failing test for skill extraction** - -Add to `describe("offline scanner", ...)` in `tests/offline.test.ts`: - -```ts -it("tags turns with the active skill from user messages", async () => { - const root = mkTmp(); - const sessions = join(root, "sessions"); - mkdirSync(sessions, { recursive: true }); - const skillMessage = JSON.stringify({ - type: "message", - id: "u1", - timestamp: "2026-05-30T10:00:00Z", - message: { - role: "user", - content: [ - { - type: "text", - text: '\nSkill content\n\nDo the thing', - }, - ], - }, - }); - const assistantTurn = JSON.stringify({ - type: "message", - id: "a1", - timestamp: "2026-05-30T10:01:00Z", - message: { - role: "assistant", - provider: "minimax", - model: "m", - usage: { input: 10, output: 10, cacheRead: 0, cacheWrite: 0, cost: 1.0 }, - }, - }); - const secondSkill = JSON.stringify({ - type: "message", - id: "u2", - timestamp: "2026-05-30T10:02:00Z", - message: { - role: "user", - content: [ - { - type: "text", - text: '\ncontent\n', - }, - ], - }, - }); - const assistantTurn2 = JSON.stringify({ - type: "message", - id: "a2", - timestamp: "2026-05-30T10:03:00Z", - message: { - role: "assistant", - provider: "minimax", - model: "m", - usage: { input: 10, output: 10, cacheRead: 0, cacheWrite: 0, cost: 2.0 }, - }, - }); - writeFileSync( - join(sessions, "s.jsonl"), - [skillMessage, assistantTurn, secondSkill, assistantTurn2].join("\n") + - "\n", - "utf8", - ); - const result = await scanOfflineUsage({ - ...createDefaultDeps(), - agentDir: () => root, - now: () => Date.parse("2026-05-30T12:00:00Z"), - }); - expect(result.turns).toHaveLength(2); - expect(result.turns[0].activeSkill).toBe("career-ops"); - expect(result.turns[1].activeSkill).toBe("writing-plans"); - rmSync(root, { recursive: true, force: true }); -}); -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `pnpm test -- tests/offline.test.ts` - -Expected: FAIL — `activeSkill` doesn't exist yet. - -- [ ] **Step 3: Implement skill extraction** - -In `src/core/offline.ts`: - -1. Add `activeSkill` to `UsageTurn`: - -```ts -export interface UsageTurn { - // ... existing fields ... - project?: string; - activeSkill?: string; -} -``` - -2. Add a skill extraction helper: - -```ts -const SKILL_NAME_RE = /; - if (row?.type !== "message") return undefined; - const message = row.message as Record | undefined; - if (message?.role !== "user") return undefined; - const content = message.content; - if (!Array.isArray(content)) return undefined; - for (const block of content) { - if ( - typeof block === "object" && - block !== null && - (block as Record).type === "text" - ) { - const text = (block as Record).text; - if (typeof text === "string") { - const match = SKILL_NAME_RE.exec(text); - if (match) return match[1]; - } - } - } - } catch { - // ignore parse errors - } - return undefined; -} -``` - -3. In the per-file scan loop, track `activeSkill` as session-level state (alongside `sessionProject`): - -```ts -let activeSkill: string | undefined; -``` - -Before calling `parseLine()`, check for skill in user messages: - -```ts -const skillName = extractSkillName(line); -if (skillName !== undefined) { - activeSkill = skillName; -} -``` - -After `parseLine()` returns a turn: - -```ts -turn.activeSkill = activeSkill; -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `pnpm test -- tests/offline.test.ts` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add src/core/offline.ts tests/offline.test.ts -git commit -m "feat(offline): extract active skill from user messages" -``` - -### Task 4.2: Extract MCP tool calls from assistant messages - -**Files:** - -- Modify: `src/core/offline.ts` -- Modify: `tests/offline.test.ts` - -- [ ] **Step 1: Write failing test for MCP tool extraction** - -Add to `describe("offline scanner", ...)` in `tests/offline.test.ts`: - -```ts -it("extracts MCP server names from tool call prefixes", async () => { - const root = mkTmp(); - const sessions = join(root, "sessions"); - mkdirSync(sessions, { recursive: true }); - const message = JSON.stringify({ - type: "message", - id: "a1", - timestamp: "2026-05-30T10:00:00Z", - message: { - role: "assistant", - provider: "minimax", - model: "m", - content: [ - { - type: "toolCall", - id: "c1", - name: "playwright_browser_click", - arguments: {}, - }, - { type: "toolCall", id: "c2", name: "read", arguments: {} }, - { type: "toolCall", id: "c3", name: "tavily", arguments: {} }, - ], - usage: { input: 10, output: 10, cacheRead: 0, cacheWrite: 0, cost: 1.0 }, - }, - }); - writeFileSync(join(sessions, "s.jsonl"), `${message}\n`, "utf8"); - const result = await scanOfflineUsage({ - ...createDefaultDeps(), - agentDir: () => root, - now: () => Date.parse("2026-05-30T12:00:00Z"), - }); - expect(result.turns).toHaveLength(1); - // "read" is built-in so excluded; "playwright" from prefix; "tavily" is single-word non-built-in - expect(result.turns[0].mcpTools).toEqual( - expect.arrayContaining(["playwright", "tavily"]), - ); - expect(result.turns[0].mcpTools).not.toContain("read"); - rmSync(root, { recursive: true, force: true }); -}); -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `pnpm test -- tests/offline.test.ts` - -Expected: FAIL — `mcpTools` doesn't exist yet. - -- [ ] **Step 3: Implement MCP tool extraction** - -In `src/core/offline.ts`: - -1. Add `mcpTools` to `UsageTurn`: - -```ts -export interface UsageTurn { - // ... existing fields ... - project?: string; - activeSkill?: string; - mcpTools?: string[]; -} -``` - -2. Add the built-in tools set and the extraction helper: - -```ts -const BUILTIN_TOOLS = new Set([ - "bash", - "read", - "write", - "edit", - "web_search", - "questionnaire", - "get_subagent_result", - "ask_user_question", - "Agent", - "mcp", -]); - -function extractMcpServers( - message: Record, -): string[] | undefined { - const content = message.content; - if (!Array.isArray(content)) return undefined; - const servers = new Set(); - for (const block of content) { - if ( - typeof block === "object" && - block !== null && - (block as Record).type === "toolCall" - ) { - const name = (block as Record).name; - if (typeof name !== "string") continue; - if (BUILTIN_TOOLS.has(name)) continue; - const firstSegment = name.split("_")[0]; - if (firstSegment) servers.add(firstSegment); - } - } - return servers.size > 0 ? [...servers] : undefined; -} -``` - -3. In `parseLine()`, after building the turn, extract MCP tools from the message: - -```ts -// In parseLine(), after constructing turnBase but before return: -const mcpTools = extractMcpServers(message as Record); -return { id, ...turnBase, mcpTools }; -``` - -Note: `parseLine` currently only looks at `message.usage` — the `message.content` array is available on the same `message` object. Just pass it to `extractMcpServers`. - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `pnpm test -- tests/offline.test.ts` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add src/core/offline.ts tests/offline.test.ts -git commit -m "feat(offline): extract MCP server names from tool call prefixes" -``` - -### Task 4.3: Add skill and MCP server insights to buildInsights - -**Files:** - -- Modify: `src/core/offline.ts` -- Modify: `tests/offline.test.ts` - -- [ ] **Step 1: Write failing tests for skill and MCP insights** - -Add to `describe("insights", ...)` in `tests/offline.test.ts`: - -```ts -it("produces skill breakdown insights", () => { - const turns = [ - { - id: "1", - sessionId: "s1", - timestamp: 1, - provider: "p", - model: "m", - input: 10, - output: 10, - cacheRead: 0, - cacheWrite: 0, - tokens: 20, - cost: 8, - activeSkill: "career-ops", - }, - { - id: "2", - sessionId: "s1", - timestamp: 2, - provider: "p", - model: "m", - input: 10, - output: 10, - cacheRead: 0, - cacheWrite: 0, - tokens: 20, - cost: 2, - }, - ]; - const insights = buildInsights(turns); - const skillInsights = insights.filter((i) => i.category === "skill"); - expect(skillInsights.length).toBeGreaterThanOrEqual(2); - expect(skillInsights[0].label).toBe("/career-ops"); - expect(skillInsights[0].detail).toContain("80.0%"); - // Turn without skill grouped as "(no skill)" - const noSkill = skillInsights.find((i) => i.label === "(no skill)"); - expect(noSkill).toBeDefined(); -}); - -it("produces MCP server breakdown insights", () => { - const turns = [ - { - id: "1", - sessionId: "s1", - timestamp: 1, - provider: "p", - model: "m", - input: 10, - output: 10, - cacheRead: 0, - cacheWrite: 0, - tokens: 20, - cost: 5, - mcpTools: ["playwright"], - }, - { - id: "2", - sessionId: "s1", - timestamp: 2, - provider: "p", - model: "m", - input: 10, - output: 10, - cacheRead: 0, - cacheWrite: 0, - tokens: 20, - cost: 3, - mcpTools: ["playwright", "firefox"], - }, - { - id: "3", - sessionId: "s1", - timestamp: 3, - provider: "p", - model: "m", - input: 10, - output: 10, - cacheRead: 0, - cacheWrite: 0, - tokens: 20, - cost: 2, - }, - ]; - const insights = buildInsights(turns); - const mcpInsights = insights.filter((i) => i.category === "mcp"); - // playwright: $5 + $3 = $8, firefox: $3 - expect(mcpInsights.length).toBeGreaterThanOrEqual(2); - expect(mcpInsights[0].label).toBe("playwright"); - expect(mcpInsights[1].label).toBe("firefox"); -}); - -it("omits skill/mcp insights when no data present", () => { - const turns = [ - { - id: "1", - sessionId: "s1", - timestamp: 1, - provider: "p", - model: "m", - input: 10, - output: 10, - cacheRead: 0, - cacheWrite: 0, - tokens: 20, - cost: 1, - }, - ]; - const insights = buildInsights(turns); - expect(insights.filter((i) => i.category === "skill")).toHaveLength(0); - expect(insights.filter((i) => i.category === "mcp")).toHaveLength(0); -}); -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `pnpm test -- tests/offline.test.ts` - -Expected: FAIL — no skill or MCP insights produced. - -- [ ] **Step 3: Implement skill and MCP insights in buildInsights** - -In `src/core/offline.ts`, in `buildInsights()`, add skill and MCP grouping after the project insights block: - -```ts -// Skill insights -const bySkill = new Map(); -let hasAnySkill = false; -for (const t of turns) { - if (t.activeSkill) { - hasAnySkill = true; - const key = `/${t.activeSkill}`; - bySkill.set(key, (bySkill.get(key) ?? 0) + t.cost); - } else { - bySkill.set("(no skill)", (bySkill.get("(no skill)") ?? 0) + t.cost); - } -} -const skillInsights: InsightItem[] = hasAnySkill - ? [...bySkill.entries()] - .sort((a, b) => b[1] - a[1]) - .map(([skill, cost]) => ({ - category: "skill", - label: skill, - cost, - detail: pct(cost), - })) - : []; - -// MCP server insights -const byMcp = new Map(); -for (const t of turns) { - if (t.mcpTools) { - for (const server of t.mcpTools) { - byMcp.set(server, (byMcp.get(server) ?? 0) + t.cost); - } - } -} -const mcpInsights: InsightItem[] = [...byMcp.entries()] - .sort((a, b) => b[1] - a[1]) - .map(([server, cost]) => ({ - category: "mcp", - label: server, - cost, - detail: pct(cost), - })); -``` - -Update the return statement to include all categories: - -```ts -return [ - ...projectInsights, - ...skillInsights, - ...mcpInsights, - { - category: "cost", - label: "Parallel sessions", - cost: parallelCost, - detail: `${pct(parallelCost)} cost while >=4 active`, - }, - // ... other 4 cost insights unchanged ... -]; -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `pnpm test -- tests/offline.test.ts` - -Expected: PASS. - -- [ ] **Step 5: Run full check** - -Run: `pnpm check` - -Expected: all lint, typecheck, and tests pass. - -- [ ] **Step 6: Commit** - -```bash -git add src/core/offline.ts tests/offline.test.ts -git commit -m "feat(insights): add skill and MCP server breakdowns" -``` - ---- - -## Final Verification - -- [ ] **Run full check one last time** - -Run: `pnpm check` - -Expected: all lint, typecheck, and tests pass. - -- [ ] **Review all changes** - -Run: `git log --oneline master..HEAD` - -Expected commits (in order): - -1. `fix(tui): add spacing between Usage Statistics title and period tabs` -2. `feat(types): add UsageConfig interface for provider toggle` -3. `feat(core): add provider enable/disable toggle via extensions/usage.json` -4. `feat(types): add category field to InsightItem and UsageInsight` -5. `feat(offline): extract project name from session CWD` -6. `feat(insights): add project breakdown by cost` -7. `feat(tui): render insights grouped by category` -8. `feat(offline): extract active skill from user messages` -9. `feat(offline): extract MCP server names from tool call prefixes` -10. `feat(insights): add skill and MCP server breakdowns` diff --git a/docs/superpowers/plans/2026-06-21-phase-1-spacing-fix.md b/docs/superpowers/plans/2026-06-21-phase-1-spacing-fix.md deleted file mode 100644 index b3da0ea..0000000 --- a/docs/superpowers/plans/2026-06-21-phase-1-spacing-fix.md +++ /dev/null @@ -1,93 +0,0 @@ -# Phase 1: Spacing Fix — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add an empty line between the "Usage Statistics" section title and the period tabs so the dashboard spacing is consistent with the "Current Usage" section. - -**Architecture:** One-line insertion in the dashboard render method, plus any test adjustments for shifted output. - -**Tech Stack:** TypeScript, Vitest, TUI rendering - -**Spec:** `docs/superpowers/specs/2026-06-21-dashboard-enhancements-design.md` → Feature 3 - -**Parent plan:** `docs/superpowers/plans/2026-06-21-dashboard-enhancements.md` → Phase 1 - ---- - -## File Map - -| File | Action | Responsibility | -| ------------------------- | ------ | ---------------------------------------------------- | -| `src/tui/dashboard.ts` | Modify | Add `lines.push("")` between section title and tabs | -| `tests/dashboard.test.ts` | Modify | Adjust any assertions affected by the new empty line | - ---- - -### Task 1.1: Add empty line between "Usage Statistics" title and period tabs - -**Files:** - -- Modify: `src/tui/dashboard.ts:364-377` -- Modify: `tests/dashboard.test.ts` (affected rendering assertions) - -- [ ] **Step 1: Update the rendering method** - -In `src/tui/dashboard.ts`, method `renderUsageStatistics()`, add an empty line between the section title and the tabs: - -```ts -// Find this block (lines 364-377): -private renderUsageStatistics(w: number, lines: string[]): void { - lines.push( - this.sectionTitle( - UI_STRINGS.dashboardBorderedSectionTitles.usageStatistics, - ), - ); - - lines.push( - ...this.renderTabs( - PERIODS.map((period) => PERIOD_LABELS[period]), - this.periodIndex, - w, - ), - ); - -// Replace with: -private renderUsageStatistics(w: number, lines: string[]): void { - lines.push( - this.sectionTitle( - UI_STRINGS.dashboardBorderedSectionTitles.usageStatistics, - ), - ); - lines.push(""); - - lines.push( - ...this.renderTabs( - PERIODS.map((period) => PERIOD_LABELS[period]), - this.periodIndex, - w, - ), - ); -``` - -- [ ] **Step 2: Run tests to check what breaks** - -Run: `pnpm test -- tests/dashboard.test.ts` - -Some render snapshot assertions may fail because they expect the old line ordering. Note which tests fail. - -- [ ] **Step 3: Fix any failing dashboard tests** - -If tests assert exact line positions or patterns that now shift by one line due to the empty line, update those assertions. The empty line appears between the "Usage Statistics" title and the `[All Time]` tab line in the render output. - -- [ ] **Step 4: Run full check** - -Run: `pnpm check` - -Expected: all lint, typecheck, and tests pass. - -- [ ] **Step 5: Commit** - -```bash -git add src/tui/dashboard.ts tests/dashboard.test.ts -git commit -m "fix(tui): add spacing between Usage Statistics title and period tabs" -``` diff --git a/docs/superpowers/plans/2026-06-21-phase-2-provider-toggle.md b/docs/superpowers/plans/2026-06-21-phase-2-provider-toggle.md deleted file mode 100644 index ada10b1..0000000 --- a/docs/superpowers/plans/2026-06-21-phase-2-provider-toggle.md +++ /dev/null @@ -1,259 +0,0 @@ -# Phase 2: Provider Enable/Disable Toggle — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Allow users to disable specific live providers via a `usage.json` config file so they produce no API calls, cache writes, or dashboard cards. - -**Architecture:** A `UsageConfig` type gates provider inclusion. `loadConfig()` reads `$PI_CODING_AGENT_DIR/extensions/usage.json` at bootstrap. Disabled providers are filtered from the registry before any fetches. All downstream logic (live runtime, cache watcher, dashboard) operates on the filtered set automatically. - -**Tech Stack:** TypeScript, Vitest, Node.js `readFile` - -**Spec:** `docs/superpowers/specs/2026-06-21-dashboard-enhancements-design.md` → Feature 1 - -**Parent plan:** `docs/superpowers/plans/2026-06-21-dashboard-enhancements.md` → Phase 2 - -**Prerequisite:** None (independent of Phase 1) - ---- - -## File Map - -| File | Action | Responsibility | -| -------------------------- | ------ | ----------------------------------------------------- | -| `src/shared/types.ts` | Modify | Add `UsageConfig` interface | -| `src/core/usage-core.ts` | Modify | Add `loadConfig()`, filter providers in `bootstrap()` | -| `tests/usage-core.test.ts` | Modify | Config loading + provider filtering tests | - ---- - -### Task 2.1: Add UsageConfig type - -**Files:** - -- Modify: `src/shared/types.ts` - -- [ ] **Step 1: Add the UsageConfig interface** - -Append after the `UsageDashboardState` interface (around line 136) in `src/shared/types.ts`: - -```ts -export interface UsageConfig { - providers?: Partial>; -} -``` - -- [ ] **Step 2: Run typecheck** - -Run: `pnpm typecheck` - -Expected: PASS (new type, no consumers yet). - -- [ ] **Step 3: Commit** - -```bash -git add src/shared/types.ts -git commit -m "feat(types): add UsageConfig interface for provider toggle" -``` - ---- - -### Task 2.2: Implement loadConfig and provider filtering - -**Files:** - -- Modify: `src/core/usage-core.ts` -- Modify: `tests/usage-core.test.ts` - -- [ ] **Step 1: Write failing tests for config loading** - -Add a new `describe("config loading", ...)` block at the end of `tests/usage-core.test.ts`. The file already imports `createUsageCore` and has a `createTestDeps(root, overrides?)` helper (lines 16-29). Add the following imports to the top of the file if not already present: - -```ts -import { join } from "node:path"; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -``` - -Then add the describe block: - -```ts -describe("config loading", () => { - function mkTmp(): string { - return mkdtempSync(join(tmpdir(), "pi-usage-config-")); - } - - it("loads config and filters disabled providers", async () => { - const root = mkTmp(); - const extDir = join(root, "extensions"); - mkdirSync(extDir, { recursive: true }); - writeFileSync( - join(extDir, "usage.json"), - JSON.stringify({ providers: { minimax: { enabled: false } } }), - ); - const sessions = join(root, "sessions"); - mkdirSync(sessions, { recursive: true }); - writeFileSync(join(sessions, "s.jsonl"), "", "utf8"); - - const core = createUsageCore({ - deps: createTestDeps(root), - onEmit: () => {}, - }); - await core.bootstrap(); - const state = core.getState(); - - expect( - state.providers.find((p) => p.providerId === "minimax"), - ).toBeUndefined(); - expect( - state.providers.find((p) => p.providerId === "openai-codex"), - ).toBeDefined(); - - rmSync(root, { recursive: true, force: true }); - }); - - it("treats missing config file as all providers enabled", async () => { - const root = mkTmp(); - const sessions = join(root, "sessions"); - mkdirSync(sessions, { recursive: true }); - writeFileSync(join(sessions, "s.jsonl"), "", "utf8"); - - const core = createUsageCore({ - deps: createTestDeps(root), - onEmit: () => {}, - }); - await core.bootstrap(); - const state = core.getState(); - - const ids = state.providers.map((p) => p.providerId); - expect(ids).toContain("openai-codex"); - expect(ids).toContain("minimax"); - - rmSync(root, { recursive: true, force: true }); - }); - - it("ignores malformed config JSON", async () => { - const root = mkTmp(); - const extDir = join(root, "extensions"); - mkdirSync(extDir, { recursive: true }); - writeFileSync(join(extDir, "usage.json"), "not json!!!"); - const sessions = join(root, "sessions"); - mkdirSync(sessions, { recursive: true }); - writeFileSync(join(sessions, "s.jsonl"), "", "utf8"); - - const core = createUsageCore({ - deps: createTestDeps(root), - onEmit: () => {}, - }); - await core.bootstrap(); - const state = core.getState(); - - const ids = state.providers.map((p) => p.providerId); - expect(ids).toContain("minimax"); - - rmSync(root, { recursive: true, force: true }); - }); -}); -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `pnpm test -- tests/usage-core.test.ts` - -Expected: FAIL — the config loading logic doesn't exist yet, so the disabled-provider test should fail. - -- [ ] **Step 3: Implement loadConfig and filtering in usage-core.ts** - -In `src/core/usage-core.ts`: - -**Add imports at the top:** - -```ts -import { join } from "node:path"; -import type { UsageConfig } from "../shared/types.ts"; -``` - -**Add `loadConfig` function before `createUsageCore`:** - -```ts -async function loadConfig(deps: UsageDeps): Promise { - try { - const configPath = join(deps.agentDir(), "extensions", "usage.json"); - const raw = await deps.readFile(configPath, "utf8"); - return JSON.parse(raw as string) as UsageConfig; - } catch { - return {}; - } -} -``` - -**Change `const` to `let` for provider registry variables (lines 52-58):** - -```ts -// Before: -const providers = createProviderRegistry(deps); -const liveProviderIds = new Set( - providers.filter((p) => p.strategy === "api").map((p) => p.id), -); -const liveProviderSnapshotFiles = new Set( - [...liveProviderIds].map((id) => `${id}.json`), -); - -// After: -let providers = createProviderRegistry(deps); -let liveProviderIds = new Set( - providers.filter((p) => p.strategy === "api").map((p) => p.id), -); -let liveProviderSnapshotFiles = new Set( - [...liveProviderIds].map((id) => `${id}.json`), -); -``` - -**Update `bootstrap()` to load config and filter (lines 202-206):** - -```ts -// Before: -async function bootstrap(): Promise { - await Promise.all([populateProviders(false), refreshOffline(false)]); - state.diagnostics = ["live runtime ready"]; - emit(USAGE_CORE_READY_EVENT); -} - -// After: -async function bootstrap(): Promise { - const config = await loadConfig(deps); - if (config.providers) { - providers = providers.filter((p) => { - const setting = config.providers?.[p.id]; - return setting?.enabled !== false; - }); - liveProviderIds = new Set( - providers.filter((p) => p.strategy === "api").map((p) => p.id), - ); - liveProviderSnapshotFiles = new Set( - [...liveProviderIds].map((id) => `${id}.json`), - ); - } - await Promise.all([populateProviders(false), refreshOffline(false)]); - state.diagnostics = ["live runtime ready"]; - emit(USAGE_CORE_READY_EVENT); -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `pnpm test -- tests/usage-core.test.ts` - -Expected: PASS — all existing tests plus the new config tests. - -- [ ] **Step 5: Run full check** - -Run: `pnpm check` - -Expected: all lint, typecheck, and tests pass. - -- [ ] **Step 6: Commit** - -```bash -git add src/core/usage-core.ts tests/usage-core.test.ts -git commit -m "feat(core): add provider enable/disable toggle via extensions/usage.json" -``` diff --git a/docs/superpowers/plans/2026-06-21-phase-3-project-insights.md b/docs/superpowers/plans/2026-06-21-phase-3-project-insights.md deleted file mode 100644 index 65aff0e..0000000 --- a/docs/superpowers/plans/2026-06-21-phase-3-project-insights.md +++ /dev/null @@ -1,695 +0,0 @@ -# Phase 3: Insight Infrastructure + Project Breakdown — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a `category` field to insights, extract project names from session CWD during the offline scan, compute a "Top projects" breakdown (capped at 5 with overflow summary), and render insights grouped by category in the dashboard. - -**Architecture:** Five tasks in sequence. Task 3.1 adds the `category` field to types and tags existing insights as `"cost"`. Task 3.2 enriches the JSONL scan to extract session CWD and set `project` on each turn. Task 3.3 adds project grouping to `buildInsights()`. Task 3.4 replaces the flat insights list in the dashboard with category-grouped rendering (tables for breakdown categories, bullet list for cost patterns). Task 3.5 caps project insights at 5, with a `+N more` overflow row aggregating the remaining projects. This also establishes the rendering infrastructure that Phase 4 builds on. - -**Tech Stack:** TypeScript, Vitest, TUI rendering - -**Spec:** `docs/superpowers/specs/2026-06-21-dashboard-enhancements-design.md` → Feature 2 (part 1) - -**Parent plan:** `docs/superpowers/plans/2026-06-21-dashboard-enhancements.md` → Phase 3 - -**Prerequisite:** None (independent of Phases 1-2) - ---- - -## File Map - -| File | Action | Responsibility | -| ------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `src/core/offline.ts` | Modify | Add `category` to `InsightItem`; add `project` to `UsageTurn`; add `projectFromCwd()` helper; extract CWD in scan loop; add project insights to `buildInsights()` | -| `src/shared/types.ts` | Modify | Add `category` to `UsageInsight` | -| `src/tui/dashboard.ts` | Modify | Add `renderInsightsByCategory()` method; update insights rendering block | -| `tests/offline.test.ts` | Modify | Project extraction tests; project insight tests | -| `tests/dashboard.test.ts` | Modify | Category-grouped rendering test | - ---- - -### Task 3.1: Add category field to InsightItem and UsageInsight - -**Files:** - -- Modify: `src/core/offline.ts` -- Modify: `src/shared/types.ts` - -- [ ] **Step 1: Add category to InsightItem in offline.ts** - -In `src/core/offline.ts`, update the `InsightItem` interface (around line 287): - -```ts -export interface InsightItem { - category?: string; - label: string; - cost: number; - detail: string; -} -``` - -- [ ] **Step 2: Add category to UsageInsight in types.ts** - -In `src/shared/types.ts`, update the `UsageInsight` interface (around line 108): - -```ts -export interface UsageInsight { - category?: string; - label: string; - cost: number; - detail: string; -} -``` - -- [ ] **Step 3: Tag existing insights with category "cost"** - -In `src/core/offline.ts`, in `buildInsights()`, add `category: "cost"` to each of the five existing insight items in the return array (around line 344): - -```ts -return [ - { - category: "cost", - label: "Parallel sessions", - cost: parallelCost, - detail: `${pct(parallelCost)} cost while >=4 active`, - }, - { - category: "cost", - label: "Large context", - cost: largeContext, - detail: `${pct(largeContext)} over 150k context`, - }, - { - category: "cost", - label: "Large uncached", - cost: largeUncached, - detail: `${pct(largeUncached)} over 100k input`, - }, - { - category: "cost", - label: "Long sessions", - cost: longSessionCost, - detail: `${pct(longSessionCost)} from 8h+ sessions`, - }, - { - category: "cost", - label: "Top-5 concentration", - cost: top5, - detail: `${pct(top5)} in top 5 sessions`, - }, -]; -``` - -- [ ] **Step 4: Run tests** - -Run: `pnpm check` - -Expected: PASS — `category` is optional, existing tests don't assert on it. - -- [ ] **Step 5: Commit** - -```bash -git add src/core/offline.ts src/shared/types.ts -git commit -m "feat(types): add category field to InsightItem and UsageInsight" -``` - ---- - -### Task 3.2: Extract session CWD and add project field to UsageTurn - -**Files:** - -- Modify: `src/core/offline.ts` -- Modify: `tests/offline.test.ts` - -- [ ] **Step 1: Write failing test for project extraction** - -Add to the `describe("offline scanner", ...)` block in `tests/offline.test.ts`: - -```ts -it("extracts project name from session header cwd", async () => { - const root = mkTmp(); - const sessions = join(root, "sessions", "proj"); - mkdirSync(sessions, { recursive: true }); - const sessionHeader = JSON.stringify({ - type: "session", - version: 3, - id: "test-session", - timestamp: "2026-05-30T10:00:00Z", - cwd: "/Users/dev/career-ops", - }); - const message = JSON.stringify({ - type: "message", - id: "m1", - timestamp: "2026-05-30T11:00:00Z", - message: { - role: "assistant", - provider: "minimax", - model: "MiniMax-M2.7", - usage: { input: 10, output: 20, cacheRead: 0, cacheWrite: 0, cost: 0.5 }, - }, - }); - writeFileSync( - join(sessions, "s.jsonl"), - `${sessionHeader}\n${message}\n`, - "utf8", - ); - const result = await scanOfflineUsage({ - ...createDefaultDeps(), - agentDir: () => root, - now: () => Date.parse("2026-05-30T12:00:00Z"), - }); - expect(result.turns).toHaveLength(1); - expect(result.turns[0].project).toBe("career-ops"); - rmSync(root, { recursive: true, force: true }); -}); - -it("falls back to undefined project when no session header", async () => { - const root = mkTmp(); - const sessions = join(root, "sessions"); - mkdirSync(sessions, { recursive: true }); - const message = JSON.stringify({ - type: "message", - id: "m1", - timestamp: "2026-05-30T11:00:00Z", - message: { - role: "assistant", - provider: "minimax", - model: "m", - usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, cost: 0.1 }, - }, - }); - writeFileSync(join(sessions, "s.jsonl"), `${message}\n`, "utf8"); - const result = await scanOfflineUsage({ - ...createDefaultDeps(), - agentDir: () => root, - }); - expect(result.turns[0].project).toBeUndefined(); - rmSync(root, { recursive: true, force: true }); -}); -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `pnpm test -- tests/offline.test.ts` - -Expected: FAIL — `project` property doesn't exist on `UsageTurn` yet. - -- [ ] **Step 3: Implement CWD extraction** - -In `src/core/offline.ts`: - -**1. Add `project` to the `UsageTurn` interface:** - -```ts -export interface UsageTurn { - id: string; - sessionId: string; - timestamp: number; - provider: string; - model: string; - input: number; - output: number; - cacheRead: number; - cacheWrite: number; - tokens: number; - cost: number; - project?: string; -} -``` - -**2. Add a helper to extract project from CWD (place near other helpers, before `scanOfflineUsage`):** - -```ts -function projectFromCwd(cwd: unknown): string | undefined { - if (typeof cwd !== "string" || !cwd) return undefined; - const segments = cwd.replace(/\/+$/, "").split("/"); - return segments[segments.length - 1] || undefined; -} -``` - -**3. In `scanOfflineUsage()`, inside the per-file loop, track session CWD.** - -The scan loop reads lines from each JSONL file. Add session-level state before the line iteration, and check for session headers: - -```ts -// Inside the per-file loop, before iterating lines: -let sessionProject: string | undefined; - -// When processing each line, before the existing parseLine call: -try { - const parsed = JSON.parse(line) as Record; - if (parsed?.type === "session" && parsed.cwd) { - sessionProject = projectFromCwd(parsed.cwd); - continue; - } -} catch { - // fall through to existing parseLine logic -} -``` - -**4. After `parseLine()` returns a turn, set `turn.project`:** - -```ts -const turn = parseLine(line, sessionId); -if (!turn) continue; -turn.project = sessionProject; -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `pnpm test -- tests/offline.test.ts` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add src/core/offline.ts tests/offline.test.ts -git commit -m "feat(offline): extract project name from session CWD" -``` - ---- - -### Task 3.3: Add project insights to buildInsights - -**Files:** - -- Modify: `src/core/offline.ts` -- Modify: `tests/offline.test.ts` - -- [ ] **Step 1: Write failing test for project insights** - -Add to the `describe("insights", ...)` block in `tests/offline.test.ts`: - -```ts -it("produces project breakdown insights", () => { - const turns = [ - { - id: "1", - sessionId: "s1", - timestamp: 1, - provider: "p", - model: "m", - input: 10, - output: 10, - cacheRead: 0, - cacheWrite: 0, - tokens: 20, - cost: 9, - project: "career-ops", - }, - { - id: "2", - sessionId: "s2", - timestamp: 2, - provider: "p", - model: "m", - input: 10, - output: 10, - cacheRead: 0, - cacheWrite: 0, - tokens: 20, - cost: 1, - project: "dotfiles", - }, - ]; - const insights = buildInsights(turns); - const projectInsights = insights.filter((i) => i.category === "project"); - expect(projectInsights.length).toBeGreaterThanOrEqual(2); - expect(projectInsights[0].label).toBe("career-ops"); - expect(projectInsights[0].detail).toContain("90.0%"); - expect(projectInsights[1].label).toBe("dotfiles"); -}); - -it("omits project insights when no projects are set", () => { - const turns = [ - { - id: "1", - sessionId: "s1", - timestamp: 1, - provider: "p", - model: "m", - input: 10, - output: 10, - cacheRead: 0, - cacheWrite: 0, - tokens: 20, - cost: 1, - }, - ]; - const insights = buildInsights(turns); - const projectInsights = insights.filter((i) => i.category === "project"); - expect(projectInsights).toHaveLength(0); -}); -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `pnpm test -- tests/offline.test.ts` - -Expected: FAIL — no insights with `category: "project"` produced yet. - -- [ ] **Step 3: Implement project insights in buildInsights** - -In `src/core/offline.ts`, in the `buildInsights()` function, add project grouping after the existing `top5` calculation but before the return statement: - -```ts -const byProject = new Map(); -for (const t of turns) { - if (t.project) { - byProject.set(t.project, (byProject.get(t.project) ?? 0) + t.cost); - } -} -const projectInsights: InsightItem[] = [...byProject.entries()] - .sort((a, b) => b[1] - a[1]) - .map(([project, cost]) => ({ - category: "project", - label: project, - cost, - detail: pct(cost), - })); -``` - -Then update the return to prepend project insights: - -```ts -return [ - ...projectInsights, - { - category: "cost", - label: "Parallel sessions", - cost: parallelCost, - detail: `${pct(parallelCost)} cost while >=4 active`, - }, - { - category: "cost", - label: "Large context", - cost: largeContext, - detail: `${pct(largeContext)} over 150k context`, - }, - { - category: "cost", - label: "Large uncached", - cost: largeUncached, - detail: `${pct(largeUncached)} over 100k input`, - }, - { - category: "cost", - label: "Long sessions", - cost: longSessionCost, - detail: `${pct(longSessionCost)} from 8h+ sessions`, - }, - { - category: "cost", - label: "Top-5 concentration", - cost: top5, - detail: `${pct(top5)} in top 5 sessions`, - }, -]; -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `pnpm test -- tests/offline.test.ts` - -Expected: PASS. The existing `"produces five insight rows"` test should still pass because those turns have no `project` field, so `projectInsights` will be empty and the array is still length 5. - -- [ ] **Step 5: Commit** - -```bash -git add src/core/offline.ts tests/offline.test.ts -git commit -m "feat(insights): add project breakdown by cost" -``` - ---- - -### Task 3.4: Category-grouped insights rendering in dashboard - -**Files:** - -- Modify: `src/tui/dashboard.ts` -- Modify: `tests/dashboard.test.ts` - -- [ ] **Step 1: Write failing test for category-grouped rendering** - -Add a new test in the `describe("dashboard rendering", ...)` block in `tests/dashboard.test.ts`: - -```ts -it("renders insights grouped by category", () => { - const state = mkState(); - state.insights = [ - { category: "project", label: "career-ops", cost: 9, detail: "90.0%" }, - { category: "project", label: "dotfiles", cost: 1, detail: "10.0%" }, - { - category: "cost", - label: "Large context", - cost: 5, - detail: "50.0% over 150k context", - }, - ]; - const c = new UsageDashboardComponent(state, () => undefined, { - theme: noTheme, - }); - c.handleInput("v"); - const out = c.render(100).join("\n"); - expect(out).toContain("Projects"); - expect(out).toContain("career-ops"); - expect(out).toContain("90.0%"); - expect(out).toContain("Cost patterns"); - expect(out).toContain("Large context"); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `pnpm test -- tests/dashboard.test.ts` - -Expected: FAIL — "Projects" category header not rendered. - -- [ ] **Step 3: Implement category-grouped rendering** - -In `src/tui/dashboard.ts`, find the insights rendering block inside `renderUsageStatistics()` (around lines 383-398). - -**Replace the flat list rendering:** - -```ts -// Find this block: -if (this.showInsights) { - lines.push( - this.sectionTitle(UI_STRINGS.dashboardBorderedSectionTitles.insights), - ); - if (this.state.insights.length === 0) { - lines.push(this.theme.dim("No insights yet.")); - } else { - for (const item of this.state.insights) { - lines.push( - this.theme.dim( - `- ${item.label}: ${formatCurrency(item.cost)} (${item.detail})`, - ), - ); - } - } - return; -} - -// Replace with: -if (this.showInsights) { - lines.push( - this.sectionTitle(UI_STRINGS.dashboardBorderedSectionTitles.insights), - ); - if (this.state.insights.length === 0) { - lines.push(this.theme.dim("No insights yet.")); - } else { - lines.push(...this.renderInsightsByCategory(w)); - } - return; -} -``` - -**Add the `renderInsightsByCategory` method to `UsageDashboardComponent`:** - -```ts -private renderInsightsByCategory(w: number): string[] { - const lines: string[] = []; - const categoryOrder = ["project", "skill", "mcp", "cost"]; - const categoryLabels: Record = { - project: "Projects", - skill: "Skills", - mcp: "MCP servers", - cost: "Cost patterns", - }; - - const grouped = new Map(); - for (const item of this.state.insights) { - const cat = item.category ?? "cost"; - const list = grouped.get(cat) ?? []; - list.push(item); - grouped.set(cat, list); - } - - for (const cat of categoryOrder) { - const items = grouped.get(cat); - if (!items || items.length === 0) continue; - lines.push(""); - const header = categoryLabels[cat] ?? cat; - if (cat === "cost") { - lines.push(this.theme.dim(header)); - for (const item of items) { - lines.push( - this.theme.dim( - ` - ${item.label}: ${formatCurrency(item.cost)} (${item.detail})`, - ), - ); - } - } else { - const pctHeader = "% of usage"; - const maxLabelLen = Math.max( - ...items.map((i) => i.label.length), - header.length, - ); - const headerLine = ` ${padVisible(this.theme.dim(header), maxLabelLen + 2, "left")} ${this.theme.dim(pctHeader)}`; - lines.push(headerLine); - for (const item of items) { - const label = padVisible( - this.theme.dim(item.label), - maxLabelLen + 2, - "left", - ); - lines.push(` ${label} ${this.theme.dim(item.detail)}`); - } - } - } - - return lines; -} -``` - -Note: `padVisible` and `formatCurrency` are already imported/available in the file. - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `pnpm test -- tests/dashboard.test.ts` - -Expected: PASS. The existing test `"uses enter/space for expand, v for insights"` uses `mkState()` which has `insights: [{ label: "x", cost: 1, detail: "y" }]` — no category, so it defaults to `"cost"` and renders under "Cost patterns". The test checks `toContain("Insights")` which is the section title, still present. - -- [ ] **Step 5: Run full check** - -Run: `pnpm check` - -Expected: all lint, typecheck, and tests pass. - -- [ ] **Step 6: Commit** - -```bash -git add src/tui/dashboard.ts tests/dashboard.test.ts -git commit -m "feat(tui): render insights grouped by category" -``` - ---- - -### Task 3.5: Cap project insights at 5 with overflow summary - -**Files:** - -- Modify: `src/core/offline.ts` -- Modify: `tests/offline.test.ts` - -- [ ] **Step 1: Write failing test for project cap with overflow** - -Add to the `describe("insights", ...)` block in `tests/offline.test.ts`, after the existing project insight tests: - -```ts -it("caps project insights at 5 with overflow summary", () => { - const turns = Array.from({ length: 7 }, (_, i) => ({ - id: String(i), - sessionId: `s${i}`, - timestamp: i, - provider: "p", - model: "m", - input: 1, - output: 1, - cacheRead: 0, - cacheWrite: 0, - tokens: 2, - cost: 7 - i, - project: `proj-${String.fromCharCode(97 + i)}`, - })); - const insights = buildInsights(turns); - const projectInsights = insights.filter((i) => i.category === "project"); - expect(projectInsights).toHaveLength(6); - expect(projectInsights[0].label).toBe("proj-a"); - expect(projectInsights[4].label).toBe("proj-e"); - expect(projectInsights[5].label).toBe("+2 more"); - expect(projectInsights[5].cost).toBe(3); - expect(projectInsights[5].detail).toContain("10.7%"); -}); -``` - -Math check: 7 turns with costs [7, 6, 5, 4, 3, 2, 1], totalCost = 28. Top 5 costs sum to 25. Remaining 2 projects cost 2 + 1 = 3. `pct(3)` = `((100 * 3) / 28).toFixed(1)` = `"10.7%"`. - -- [ ] **Step 2: Run test to verify it fails** - -Run: `pnpm test -- tests/offline.test.ts` - -Expected: FAIL — currently all 7 projects are returned, but test expects only 6 items (5 + overflow). - -- [ ] **Step 3: Implement project cap in buildInsights** - -In `src/core/offline.ts`, replace the current `projectInsights` construction (lines 370-377): - -```ts -// Replace this: -const projectInsights: InsightItem[] = [...byProject.entries()] - .sort((a, b) => b[1] - a[1]) - .map(([project, cost]) => ({ - category: "project", - label: project, - cost, - detail: pct(cost), - })); - -// With this: -const maxProjects = 5; -const allProjectEntries = [...byProject.entries()].sort( - (a, b) => b[1] - a[1], -); -const projectInsights: InsightItem[] = allProjectEntries - .slice(0, maxProjects) - .map(([project, cost]) => ({ - category: "project", - label: project, - cost, - detail: pct(cost), - })); -if (allProjectEntries.length > maxProjects) { - const remainingCost = allProjectEntries - .slice(maxProjects) - .reduce((sum, [, c]) => sum + c, 0); - projectInsights.push({ - category: "project", - label: `+${allProjectEntries.length - maxProjects} more`, - cost: remainingCost, - detail: pct(remainingCost), - }); -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `pnpm test -- tests/offline.test.ts` - -Expected: PASS. The existing `"produces project breakdown insights"` test (2 projects, under the cap) still passes unchanged. The new test verifies the cap and overflow row. - -- [ ] **Step 5: Run full check** - -Run: `pnpm check` - -Expected: all lint, typecheck, and tests pass. - -- [ ] **Step 6: Commit** - -```bash -git add src/core/offline.ts tests/offline.test.ts -git commit -m "feat(insights): cap project insights at 5 with overflow summary" -``` diff --git a/docs/superpowers/plans/2026-06-21-phase-4-skill-mcp-insights.md b/docs/superpowers/plans/2026-06-21-phase-4-skill-mcp-insights.md deleted file mode 100644 index b31fed3..0000000 --- a/docs/superpowers/plans/2026-06-21-phase-4-skill-mcp-insights.md +++ /dev/null @@ -1,679 +0,0 @@ -# Phase 4: Skill and MCP Server Breakdowns — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Extract skill invocations and MCP tool calls from session JSONL data, then compute "Top skills" and "Top MCP servers" insight categories that render alongside the project breakdown from Phase 3. - -**Architecture:** Three tasks. Task 4.1 extracts skill names from user messages containing `` tags and tracks the active skill per session. Task 4.2 extracts MCP server names from assistant tool calls by taking the first underscore-separated segment (excluding known built-in tools). Task 4.3 adds skill and MCP grouping to `buildInsights()`. The category-grouped rendering from Phase 3 picks up the new categories automatically. - -**Tech Stack:** TypeScript, Vitest, regex extraction - -**Spec:** `docs/superpowers/specs/2026-06-21-dashboard-enhancements-design.md` → Feature 2 (part 2) - -**Parent plan:** `docs/superpowers/plans/2026-06-21-dashboard-enhancements.md` → Phase 4 - -**Prerequisite:** Phase 3 must be completed first (this phase depends on the `category` field on `InsightItem`, the `project` field on `UsageTurn`, and the category-grouped rendering in the dashboard). - ---- - -## File Map - -| File | Action | Responsibility | -| ----------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `src/core/offline.ts` | Modify | Add `activeSkill` and `mcpTools` to `UsageTurn`; add `extractSkillName()`, `extractMcpServers()`, `BUILTIN_TOOLS`; extract in scan loop; add skill/MCP insights to `buildInsights()` | -| `tests/offline.test.ts` | Modify | Skill extraction, MCP extraction, skill insights, MCP insights tests | - ---- - -### Task 4.1: Extract skill invocations from user messages - -**Files:** - -- Modify: `src/core/offline.ts` -- Modify: `tests/offline.test.ts` - -- [ ] **Step 1: Write failing test for skill extraction** - -Add to `describe("offline scanner", ...)` in `tests/offline.test.ts`: - -```ts -it("tags turns with the active skill from user messages", async () => { - const root = mkTmp(); - const sessions = join(root, "sessions"); - mkdirSync(sessions, { recursive: true }); - const skillMessage = JSON.stringify({ - type: "message", - id: "u1", - timestamp: "2026-05-30T10:00:00Z", - message: { - role: "user", - content: [ - { - type: "text", - text: '\nSkill content\n\nDo the thing', - }, - ], - }, - }); - const assistantTurn = JSON.stringify({ - type: "message", - id: "a1", - timestamp: "2026-05-30T10:01:00Z", - message: { - role: "assistant", - provider: "minimax", - model: "m", - usage: { input: 10, output: 10, cacheRead: 0, cacheWrite: 0, cost: 1.0 }, - }, - }); - const secondSkill = JSON.stringify({ - type: "message", - id: "u2", - timestamp: "2026-05-30T10:02:00Z", - message: { - role: "user", - content: [ - { - type: "text", - text: '\ncontent\n', - }, - ], - }, - }); - const assistantTurn2 = JSON.stringify({ - type: "message", - id: "a2", - timestamp: "2026-05-30T10:03:00Z", - message: { - role: "assistant", - provider: "minimax", - model: "m", - usage: { input: 10, output: 10, cacheRead: 0, cacheWrite: 0, cost: 2.0 }, - }, - }); - writeFileSync( - join(sessions, "s.jsonl"), - [skillMessage, assistantTurn, secondSkill, assistantTurn2].join("\n") + - "\n", - "utf8", - ); - const result = await scanOfflineUsage({ - ...createDefaultDeps(), - agentDir: () => root, - now: () => Date.parse("2026-05-30T12:00:00Z"), - }); - expect(result.turns).toHaveLength(2); - expect(result.turns[0].activeSkill).toBe("career-ops"); - expect(result.turns[1].activeSkill).toBe("writing-plans"); - rmSync(root, { recursive: true, force: true }); -}); -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `pnpm test -- tests/offline.test.ts` - -Expected: FAIL — `activeSkill` doesn't exist on `UsageTurn` yet. - -- [ ] **Step 3: Implement skill extraction** - -In `src/core/offline.ts`: - -**1. Add `activeSkill` to the `UsageTurn` interface:** - -```ts -export interface UsageTurn { - id: string; - sessionId: string; - timestamp: number; - provider: string; - model: string; - input: number; - output: number; - cacheRead: number; - cacheWrite: number; - tokens: number; - cost: number; - project?: string; - activeSkill?: string; -} -``` - -**2. Add a skill extraction helper (place near other helpers, before `scanOfflineUsage`):** - -```ts -const SKILL_NAME_RE = /; - if (row?.type !== "message") return undefined; - const message = row.message as Record | undefined; - if (message?.role !== "user") return undefined; - const content = message.content; - if (!Array.isArray(content)) return undefined; - for (const block of content) { - if ( - typeof block === "object" && - block !== null && - (block as Record).type === "text" - ) { - const text = (block as Record).text; - if (typeof text === "string") { - const match = SKILL_NAME_RE.exec(text); - if (match) return match[1]; - } - } - } - } catch { - // ignore parse errors - } - return undefined; -} -``` - -**3. In the per-file scan loop (inside `scanOfflineUsage`), track `activeSkill` as session-level state alongside `sessionProject`:** - -```ts -let activeSkill: string | undefined; -``` - -Before calling `parseLine()`, check for skill invocations: - -```ts -const skillName = extractSkillName(line); -if (skillName !== undefined) { - activeSkill = skillName; -} -``` - -After `parseLine()` returns a turn, set the active skill: - -```ts -turn.activeSkill = activeSkill; -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `pnpm test -- tests/offline.test.ts` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add src/core/offline.ts tests/offline.test.ts -git commit -m "feat(offline): extract active skill from user messages" -``` - ---- - -### Task 4.2: Extract MCP tool calls from assistant messages - -**Files:** - -- Modify: `src/core/offline.ts` -- Modify: `tests/offline.test.ts` - -- [ ] **Step 1: Write failing test for MCP tool extraction** - -Add to `describe("offline scanner", ...)` in `tests/offline.test.ts`: - -```ts -it("extracts MCP server names from tool call prefixes", async () => { - const root = mkTmp(); - const sessions = join(root, "sessions"); - mkdirSync(sessions, { recursive: true }); - const message = JSON.stringify({ - type: "message", - id: "a1", - timestamp: "2026-05-30T10:00:00Z", - message: { - role: "assistant", - provider: "minimax", - model: "m", - content: [ - { - type: "toolCall", - id: "c1", - name: "playwright_browser_click", - arguments: {}, - }, - { type: "toolCall", id: "c2", name: "read", arguments: {} }, - { type: "toolCall", id: "c3", name: "tavily", arguments: {} }, - ], - usage: { input: 10, output: 10, cacheRead: 0, cacheWrite: 0, cost: 1.0 }, - }, - }); - writeFileSync(join(sessions, "s.jsonl"), `${message}\n`, "utf8"); - const result = await scanOfflineUsage({ - ...createDefaultDeps(), - agentDir: () => root, - now: () => Date.parse("2026-05-30T12:00:00Z"), - }); - expect(result.turns).toHaveLength(1); - // "read" is built-in so excluded; "playwright" from prefix; "tavily" is single-word non-built-in - expect(result.turns[0].mcpTools).toEqual( - expect.arrayContaining(["playwright", "tavily"]), - ); - expect(result.turns[0].mcpTools).not.toContain("read"); - rmSync(root, { recursive: true, force: true }); -}); -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `pnpm test -- tests/offline.test.ts` - -Expected: FAIL — `mcpTools` doesn't exist on `UsageTurn` yet. - -- [ ] **Step 3: Implement MCP tool extraction** - -In `src/core/offline.ts`: - -**1. Add `mcpTools` to `UsageTurn`:** - -```ts -export interface UsageTurn { - id: string; - sessionId: string; - timestamp: number; - provider: string; - model: string; - input: number; - output: number; - cacheRead: number; - cacheWrite: number; - tokens: number; - cost: number; - project?: string; - activeSkill?: string; - mcpTools?: string[]; -} -``` - -**2. Add the built-in tools set and extraction helper (place near other helpers):** - -```ts -const BUILTIN_TOOLS = new Set([ - "bash", - "read", - "write", - "edit", - "grep", - "ls", - "find", -]); - -function extractMcpServers( - message: Record, -): string[] | undefined { - const content = message.content; - if (!Array.isArray(content)) return undefined; - const servers = new Set(); - for (const block of content) { - if ( - typeof block === "object" && - block !== null && - (block as Record).type === "toolCall" - ) { - const name = (block as Record).name; - if (typeof name !== "string") continue; - if (BUILTIN_TOOLS.has(name)) continue; - const firstSegment = name.split("_")[0]; - if (firstSegment) servers.add(firstSegment); - } - } - return servers.size > 0 ? [...servers] : undefined; -} -``` - -**3. In `parseLine()`, extract MCP tools from the message.** - -After building the turn object but before returning it, call `extractMcpServers`: - -```ts -// In parseLine(), after constructing the turn fields but before return: -const mcpTools = extractMcpServers(message as Record); -// Include mcpTools in the returned object -return { - id, - sessionId, - timestamp, - provider, - model, - input, - output, - cacheRead, - cacheWrite, - tokens, - cost, - mcpTools, -}; -``` - -The exact integration depends on how `parseLine` currently constructs and returns the turn. The key change: pass the `message` object to `extractMcpServers()` and include the result in the returned `UsageTurn`. - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `pnpm test -- tests/offline.test.ts` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add src/core/offline.ts tests/offline.test.ts -git commit -m "feat(offline): extract MCP server names from tool call prefixes" -``` - ---- - -### Task 4.3: Add skill and MCP server insights to buildInsights - -**Files:** - -- Modify: `src/core/offline.ts` -- Modify: `tests/offline.test.ts` - -- [ ] **Step 1: Write failing tests for skill and MCP insights** - -Add to `describe("insights", ...)` in `tests/offline.test.ts`: - -```ts -it("produces skill breakdown insights", () => { - const turns = [ - { - id: "1", - sessionId: "s1", - timestamp: 1, - provider: "p", - model: "m", - input: 10, - output: 10, - cacheRead: 0, - cacheWrite: 0, - tokens: 20, - cost: 8, - activeSkill: "career-ops", - }, - { - id: "2", - sessionId: "s1", - timestamp: 2, - provider: "p", - model: "m", - input: 10, - output: 10, - cacheRead: 0, - cacheWrite: 0, - tokens: 20, - cost: 2, - }, - ]; - const insights = buildInsights(turns); - const skillInsights = insights.filter((i) => i.category === "skill"); - expect(skillInsights.length).toBeGreaterThanOrEqual(2); - expect(skillInsights[0].label).toBe("/career-ops"); - expect(skillInsights[0].detail).toContain("80.0%"); - const noSkill = skillInsights.find((i) => i.label === "(no skill)"); - expect(noSkill).toBeDefined(); -}); - -it("produces MCP server breakdown insights", () => { - const turns = [ - { - id: "1", - sessionId: "s1", - timestamp: 1, - provider: "p", - model: "m", - input: 10, - output: 10, - cacheRead: 0, - cacheWrite: 0, - tokens: 20, - cost: 5, - mcpTools: ["playwright"], - }, - { - id: "2", - sessionId: "s1", - timestamp: 2, - provider: "p", - model: "m", - input: 10, - output: 10, - cacheRead: 0, - cacheWrite: 0, - tokens: 20, - cost: 3, - mcpTools: ["playwright", "firefox"], - }, - { - id: "3", - sessionId: "s1", - timestamp: 3, - provider: "p", - model: "m", - input: 10, - output: 10, - cacheRead: 0, - cacheWrite: 0, - tokens: 20, - cost: 2, - }, - ]; - const insights = buildInsights(turns); - const mcpInsights = insights.filter((i) => i.category === "mcp"); - // playwright: $5 + $3 = $8, firefox: $3 - expect(mcpInsights.length).toBeGreaterThanOrEqual(2); - expect(mcpInsights[0].label).toBe("playwright"); - expect(mcpInsights[1].label).toBe("firefox"); -}); - -it("caps skill insights at 5 with overflow summary", () => { - const turns = Array.from({ length: 7 }, (_, i) => ({ - id: String(i), - sessionId: `s${i}`, - timestamp: i, - provider: "p", - model: "m", - input: 1, - output: 1, - cacheRead: 0, - cacheWrite: 0, - tokens: 2, - cost: 7 - i, - activeSkill: `skill-${String.fromCharCode(97 + i)}`, - })); - const insights = buildInsights(turns); - const skillInsights = insights.filter((i) => i.category === "skill"); - expect(skillInsights).toHaveLength(6); - expect(skillInsights[0].label).toBe("/skill-a"); - expect(skillInsights[4].label).toBe("/skill-e"); - expect(skillInsights[5].label).toBe("+2 more"); - expect(skillInsights[5].cost).toBe(3); -}); - -it("caps MCP insights at 5 with overflow summary", () => { - const turns = Array.from({ length: 7 }, (_, i) => ({ - id: String(i), - sessionId: `s${i}`, - timestamp: i, - provider: "p", - model: "m", - input: 1, - output: 1, - cacheRead: 0, - cacheWrite: 0, - tokens: 2, - cost: 7 - i, - mcpTools: [`server-${String.fromCharCode(97 + i)}`], - })); - const insights = buildInsights(turns); - const mcpInsights = insights.filter((i) => i.category === "mcp"); - expect(mcpInsights).toHaveLength(6); - expect(mcpInsights[0].label).toBe("server-a"); - expect(mcpInsights[4].label).toBe("server-e"); - expect(mcpInsights[5].label).toBe("+2 more"); - expect(mcpInsights[5].cost).toBe(3); -}); - -it("omits skill/mcp insights when no data present", () => { - const turns = [ - { - id: "1", - sessionId: "s1", - timestamp: 1, - provider: "p", - model: "m", - input: 10, - output: 10, - cacheRead: 0, - cacheWrite: 0, - tokens: 20, - cost: 1, - }, - ]; - const insights = buildInsights(turns); - expect(insights.filter((i) => i.category === "skill")).toHaveLength(0); - expect(insights.filter((i) => i.category === "mcp")).toHaveLength(0); -}); -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `pnpm test -- tests/offline.test.ts` - -Expected: FAIL — no skill or MCP insights produced. - -- [ ] **Step 3: Implement skill and MCP insights in buildInsights** - -In `src/core/offline.ts`, in `buildInsights()`, add skill and MCP grouping after the `projectInsights` block (added in Phase 3, Task 3.3) and before the return: - -```ts -// Skill insights -const bySkill = new Map(); -let hasAnySkill = false; -for (const t of turns) { - if (t.activeSkill) { - hasAnySkill = true; - const key = `/${t.activeSkill}`; - bySkill.set(key, (bySkill.get(key) ?? 0) + t.cost); - } else { - bySkill.set("(no skill)", (bySkill.get("(no skill)") ?? 0) + t.cost); - } -} -const allSkillEntries = hasAnySkill - ? [...bySkill.entries()].sort((a, b) => b[1] - a[1]) - : []; -const skillInsights: InsightItem[] = allSkillEntries - .slice(0, maxProjects) - .map(([skill, cost]) => ({ - category: "skill", - label: skill, - cost, - detail: pct(cost), - })); -if (allSkillEntries.length > maxProjects) { - const remainingCost = allSkillEntries - .slice(maxProjects) - .reduce((sum, [, c]) => sum + c, 0); - skillInsights.push({ - category: "skill", - label: `+${allSkillEntries.length - maxProjects} more`, - cost: remainingCost, - detail: pct(remainingCost), - }); -} - -// MCP server insights -const byMcp = new Map(); -for (const t of turns) { - if (t.mcpTools) { - for (const server of t.mcpTools) { - byMcp.set(server, (byMcp.get(server) ?? 0) + t.cost); - } - } -} -const allMcpEntries = [...byMcp.entries()].sort((a, b) => b[1] - a[1]); -const mcpInsights: InsightItem[] = allMcpEntries - .slice(0, maxProjects) - .map(([server, cost]) => ({ - category: "mcp", - label: server, - cost, - detail: pct(cost), - })); -if (allMcpEntries.length > maxProjects) { - const remainingCost = allMcpEntries - .slice(maxProjects) - .reduce((sum, [, c]) => sum + c, 0); - mcpInsights.push({ - category: "mcp", - label: `+${allMcpEntries.length - maxProjects} more`, - cost: remainingCost, - detail: pct(remainingCost), - }); -} -``` - -Update the return to include all categories in order: - -```ts -return [ - ...projectInsights, - ...skillInsights, - ...mcpInsights, - { - category: "cost", - label: "Parallel sessions", - cost: parallelCost, - detail: `${pct(parallelCost)} cost while >=4 active`, - }, - { - category: "cost", - label: "Large context", - cost: largeContext, - detail: `${pct(largeContext)} over 150k context`, - }, - { - category: "cost", - label: "Large uncached", - cost: largeUncached, - detail: `${pct(largeUncached)} over 100k input`, - }, - { - category: "cost", - label: "Long sessions", - cost: longSessionCost, - detail: `${pct(longSessionCost)} from 8h+ sessions`, - }, - { - category: "cost", - label: "Top-5 concentration", - cost: top5, - detail: `${pct(top5)} in top 5 sessions`, - }, -]; -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `pnpm test -- tests/offline.test.ts` - -Expected: PASS. - -- [ ] **Step 5: Run full check** - -Run: `pnpm check` - -Expected: all lint, typecheck, and tests pass. - -- [ ] **Step 6: Commit** - -```bash -git add src/core/offline.ts tests/offline.test.ts -git commit -m "feat(insights): add skill and MCP server breakdowns" -``` diff --git a/docs/superpowers/specs/2026-06-21-dashboard-enhancements-design.md b/docs/superpowers/specs/2026-06-21-dashboard-enhancements-design.md deleted file mode 100644 index 27900fd..0000000 --- a/docs/superpowers/specs/2026-06-21-dashboard-enhancements-design.md +++ /dev/null @@ -1,239 +0,0 @@ -# Dashboard Enhancements Design - -Three independent improvements to the pi-usage dashboard: provider enable/disable toggle, richer insights from session data, and a spacing fix. - -## Feature 1: Provider Enable/Disable Toggle - -### Problem - -Live provider cards appear for every provider that has credentials configured. There is no way to suppress a provider you have env vars set for but don't want pi-usage to query. - -### Solution - -A per-provider toggle stored in a config file. Disabled providers are filtered out at startup -- no API calls, no cache writes, no dashboard cards. - -### Config file - -**Path:** `$PI_CODING_AGENT_DIR/extensions/usage.json` - -```json -{ - "providers": { - "minimax": { "enabled": false }, - "stepfun": { "enabled": false } - } -} -``` - -**Rules:** - -- File is optional. Missing file means all providers enabled. -- Unmentioned providers default to enabled. -- `{ "enabled": false }` disables the provider entirely. -- Config is read once at `UsageCore` initialization. Changes require Pi restart or `/reload`. - -### Type - -```ts -// src/shared/types.ts -export interface UsageConfig { - providers?: Partial>; -} -``` - -### Code changes - -**`src/core/usage-core.ts`:** - -- Add `loadConfig(deps: UsageDeps): Promise` that reads `join(deps.agentDir(), "extensions", "usage.json")`, parses JSON, returns `UsageConfig`. Returns `{}` on missing file or parse error. -- In `UsageCore` constructor (or `init()`), after `createProviderRegistry(deps)`, filter the provider list: remove any provider where `config.providers?.[id]?.enabled === false`. -- `isLiveProvider()`, `startLiveRuntime()`, and all downstream logic operate on the filtered registry, so disabled providers vanish automatically. - -**`src/tui/dashboard.ts`:** No changes. Dashboard renders what `state.providers` contains, which already excludes filtered-out providers. - ---- - -## Feature 2: Richer Insights - -### Problem - -The current insights (toggled with `v`) show five cost-pattern categories: parallel sessions, large context, large uncached, long sessions, and top-5 concentration. These are useful but miss the most actionable information: which projects, skills, and MCP servers are driving usage. - -### Solution - -Enrich the offline scan to extract additional data from session JSONL files, then compute new insight categories for projects, skills, and MCP servers. - -### Data extraction - -Enhance the JSONL scan in `src/core/offline.ts` to extract three new data points per session/turn: - -**1. Session CWD (project attribution):** - -- Source: `type: "session"` header entry (first line of each JSONL file), `cwd` field. -- Derive project name from last path segment (e.g. `/Users/lanh/Developer/career-ops` -> `career-ops`). -- Tag every turn in that session with the project name. - -**2. Skill invocations:** - -- Source: `type: "message"` entries with `role: "user"`, text content containing ``. -- Extract skill name with regex: ` `playwright`, `firefox_devtools_take_snapshot` -> `firefox`, `minimax_coding_plan_web_search` -> `minimax`). This is a heuristic -- multi-word server names like `firefox-devtools` will appear as their first segment (`firefox`). Acceptable for v1; can be refined with a known-prefix mapping later. -- Built-in tools are excluded from MCP attribution. Known built-in tools (from the Pi SDK's `core/tools/`): `bash`, `read`, `write`, `edit`, `grep`, `ls`, `find`. -- Single-word tool names that aren't built-in (e.g. `tavily`, `ddgs`, `exa`, `firecrawl`) are treated as direct MCP server calls and attributed to that server name. - -### Enriched turn type - -```ts -// src/core/offline.ts -export interface UsageTurn { - // existing fields - id: string; - sessionId: string; - timestamp: number; - provider: string; - model: string; - input: number; - output: number; - cacheRead: number; - cacheWrite: number; - tokens: number; - cost: number; - // new fields - project?: string; - activeSkill?: string; - mcpTools?: string[]; -} -``` - -### New insight categories - -Added to the output of `buildInsights()`. Each entry uses the existing `InsightItem` type with an added `category` field: - -```ts -// src/core/offline.ts -export interface InsightItem { - category?: string; // "project" | "skill" | "mcp" | "cost" - label: string; - cost: number; - detail: string; -} -``` - -**Top projects:** Group turns by `project`, sum cost, sort by cost descending, cap at 5. If more than 5 projects exist, append one summary row (`+N more`) aggregating the remaining projects' cost and percentage. Label: project name. Detail: percentage of total cost. - -**Top skills:** Group turns by `activeSkill` (where set), sum cost, sort by cost descending, cap at 5. If more than 5 skills exist, append one summary row (`+N more`) aggregating the remaining skills' cost and percentage. Turns with no active skill are grouped as "(no skill)". Label: skill name (with `/` prefix for display). Detail: percentage of total cost. - -**Top MCP servers:** For each turn, extract unique MCP server prefixes from `mcpTools`. Attribute that turn's full cost to each server used. Sort by cost descending, cap at 5. If more than 5 servers exist, append one summary row (`+N more`) aggregating the remaining servers' cost and percentage. Label: server name. Detail: percentage of total cost. - -Existing five cost-pattern insights remain, tagged with `category: "cost"`. - -### Dashboard rendering - -The insights view (toggled with `v`) groups insights by category: - -``` -Insights - - Projects % of usage - career-ops 91.2% - pi-packages 6.1% - dotfiles 2.7% - - Skills % of usage - /executing-plans 45.6% - /writing-plans 22.3% - (no skill) 32.1% - - MCP servers % of usage - playwright 50.0% - firefox-devtools 12.3% - - Cost patterns - - Parallel sessions: $0.50 (10.8%) - - Large context: $1.20 (26.0%) - - Top-5 concentration: $3.80 (82.4%) -``` - -Rendering changes in `src/tui/dashboard.ts`: - -- Group `state.insights` by `category`. -- For `project`, `skill`, `mcp` categories: render as a two-column table (label + percentage), with a category header. -- For `cost` category (or items without a category): render as the existing `- label: $cost (detail)` format. - ---- - -## Feature 3: Spacing Fix - -### Problem - -The "Usage Statistics" section title runs directly into the period tabs with no visual separation. The "Current Usage" section has a border separator between its title and tabs, creating an inconsistency. - -### Solution - -Add one empty line between the "Usage Statistics" section title and the period tabs. - -### Code change - -**`src/tui/dashboard.ts`, `renderUsageStatistics()` method:** - -```ts -// Before -lines.push( - this.sectionTitle(UI_STRINGS.dashboardBorderedSectionTitles.usageStatistics), -); -lines.push( - ...this.renderTabs( - PERIODS.map((period) => PERIOD_LABELS[period]), - this.periodIndex, - w, - ), -); - -// After -lines.push( - this.sectionTitle(UI_STRINGS.dashboardBorderedSectionTitles.usageStatistics), -); -lines.push(""); -lines.push( - ...this.renderTabs( - PERIODS.map((period) => PERIOD_LABELS[period]), - this.periodIndex, - w, - ), -); -``` - ---- - -## Testing - -### Feature 1 (provider toggle) - -- Unit test: `loadConfig()` returns empty object for missing file, parses valid config, ignores malformed JSON. -- Unit test: `UsageCore` with config disabling a provider excludes it from the registry. -- Integration: disabled provider has no snapshot in `state.providers`. - -### Feature 2 (richer insights) - -- Unit test: `parseLine()` / scan loop extracts `project`, `activeSkill`, `mcpTools` from session entries. -- Unit test: skill tagging propagates across turns within a session. -- Unit test: MCP server inference from tool name prefixes (underscore splitting, known built-ins excluded). -- Unit test: `buildInsights()` produces correct project/skill/mcp categories with percentages. -- Snapshot test: insights rendering groups by category. - -### Feature 3 (spacing) - -- Existing dashboard render tests should be updated to expect the extra empty line. - -## Verification - -- `pnpm check` (lint + typecheck + tests) passes. -- Manual verification: open `/usage` dashboard, toggle insights with `v`, confirm new categories render. -- Manual verification: disable a provider in config, reload Pi, confirm provider card is absent and no API calls are made. diff --git a/docs/superpowers/specs/2026-06-21-project-insights-cap-design.md b/docs/superpowers/specs/2026-06-21-project-insights-cap-design.md deleted file mode 100644 index 065816e..0000000 --- a/docs/superpowers/specs/2026-06-21-project-insights-cap-design.md +++ /dev/null @@ -1,46 +0,0 @@ -# Project Insights Cap - -Limit project breakdown insights to the top 5 projects by cost, with a summary overflow row when more exist. - -## Problem - -The project insights list grows unbounded. Users with many session directories see a long list that overwhelms the insights view and pushes cost-pattern insights off screen. - -## Solution - -In `buildInsights()`, after sorting projects by cost descending, take only the first 5. If more than 5 projects exist, append a summary item that aggregates the remaining projects' cost and percentage. - -### Data layer (`src/core/offline.ts`) - -After sorting `projectInsights` by cost descending: - -1. If `projectInsights.length <= 5`, return as-is (no change). -2. If `projectInsights.length > 5`: - - Keep the first 5 items. - - Sum the cost of remaining items. - - Append one summary item: `{ category: "project", label: "+N more", cost: remainingCost, detail: pct(remainingCost) }` where N is the count of remaining projects. - -### Dashboard rendering - -No changes. The renderer already handles any `InsightItem` with `category: "project"`. The overflow row renders as a normal project row in the table. - -### Rendered example - -``` - Projects % of usage - career-ops 42.3% - dotfiles 18.1% - pi-vault 12.0% - homelab 8.5% - scripts 6.2% - +12 more 12.9% -``` - -## Testing - -- Existing test with 2 projects: unaffected (under cap). -- New test with 7 projects: verify only 6 items returned (5 individual + 1 overflow), overflow label is "+2 more", overflow cost is sum of bottom 2 projects' costs, overflow detail contains the correct percentage. - -## Verification - -`pnpm check` passes. diff --git a/package.json b/package.json index 8d0c02a..81f4590 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pi-vault/pi-usage", - "version": "0.4.0", + "version": "0.5.0", "type": "module", "description": "Pi extension that displays aggregated usage statistics across all sessions", "author": "Lanh Hoang ", @@ -38,26 +38,25 @@ ] }, "engines": { - "node": ">=22.19" + "node": ">=24.15.0" }, "files": [ "src", "docs/assets", + "LICENSE", + "CHANGELOG.md", "README.md" ], - "publishConfig": { - "access": "public" - }, "peerDependencies": { "@earendil-works/pi-coding-agent": "*", "@earendil-works/pi-tui": "*" }, "devDependencies": { - "@biomejs/biome": "^2.4.16", - "@earendil-works/pi-coding-agent": "^0.79.3", - "@earendil-works/pi-tui": "^0.79.3", - "@types/node": "^25.9.1", + "@biomejs/biome": "^2.5.0", + "@earendil-works/pi-coding-agent": "^0.79.8", + "@earendil-works/pi-tui": "^0.79.8", + "@types/node": "^25.9.3", "typescript": "^6.0.3", - "vitest": "^4.1.7" + "vitest": "^4.1.8" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de2f1dd..3d4195e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,23 +9,23 @@ importers: .: devDependencies: '@biomejs/biome': - specifier: ^2.4.16 - version: 2.4.16 + specifier: ^2.5.0 + version: 2.5.0 '@earendil-works/pi-coding-agent': - specifier: ^0.79.3 - version: 0.79.3(ws@8.21.0)(zod@4.4.3) + specifier: ^0.79.8 + version: 0.79.9(ws@8.21.0)(zod@4.4.3) '@earendil-works/pi-tui': - specifier: ^0.79.3 - version: 0.79.3 + specifier: ^0.79.8 + version: 0.79.9 '@types/node': - specifier: ^25.9.1 - version: 25.9.1 + specifier: ^25.9.3 + version: 25.9.4 typescript: specifier: ^6.0.3 version: 6.0.3 vitest: - specifier: ^4.1.7 - version: 4.1.7(@types/node@25.9.1)(vite@8.0.14(@types/node@25.9.1)(jiti@2.7.0)(yaml@2.9.0)) + specifier: ^4.1.8 + version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@25.9.4)(vite@8.0.14(@types/node@25.9.4)(jiti@2.7.0)(yaml@2.9.0)) packages: @@ -143,79 +143,79 @@ packages: resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} - '@biomejs/biome@2.4.16': - resolution: {integrity: sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA==} + '@biomejs/biome@2.5.0': + resolution: {integrity: sha512-4kURkd9hAPrdDM3C9n82ycYgx8hvQcW6MjKTEejruj8rK0N8P3OPpdy8BvI8kt3KWY4ycF5XtDOrktetEfhfuw==} engines: {node: '>=14.21.3'} hasBin: true - '@biomejs/cli-darwin-arm64@2.4.16': - resolution: {integrity: sha512-wxPvu4XOA85YJk9ixSWUmq/QBHbid85BISbOAqqBM/5xQpPk9ayjk5375tOlSC0BeCwNSbPFafQBm+vBumXq0A==} + '@biomejs/cli-darwin-arm64@2.5.0': + resolution: {integrity: sha512-Mn3Fwi3SA5fgmfCPqmzpWF2DLZnms3BVAhM088nTnGrTZmHS3wwIjcoZPqpXeNgd3DrrLH6xp8vTLIBuJoZiXw==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [darwin] - '@biomejs/cli-darwin-x64@2.4.16': - resolution: {integrity: sha512-xFCqGPwYusQJp4N4NJLi1XJiZqjwFdjhT+KqtNy+Ug3qgfczqnTa6MSDvxJF6TkuDLoYJItMapz6tAf7kCekFw==} + '@biomejs/cli-darwin-x64@2.5.0': + resolution: {integrity: sha512-rg3VPL5P8mYro6pqlXYXuJWph21slVp3SZtAqWSrkZs40d2gTzYmHF8E/X1iTID25btmNKltNDJ926sqVBp7DQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [darwin] - '@biomejs/cli-linux-arm64-musl@2.4.16': - resolution: {integrity: sha512-oYxnW0ARfJkr72ezzF2OR8N/rtkgLUQeYtF8cFhVswbknHxtTcmzSsanVJP8yQKnGpGpc2ck6c5zLvHahL6Cbg==} + '@biomejs/cli-linux-arm64-musl@2.5.0': + resolution: {integrity: sha512-vQdM4oSGaf7ZNeGO9w5+Y8SBtyser9M6znxYbm7Ec8wInxJu1WiKxFYZW5Auj2d80bcVvefuGGRxoFOE0eee8g==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [musl] - '@biomejs/cli-linux-arm64@2.4.16': - resolution: {integrity: sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ==} + '@biomejs/cli-linux-arm64@2.5.0': + resolution: {integrity: sha512-tl+LW8fdD96/xdeWtWwc82LIOc5CoY7N2AsogLTp5R4ECErYt+8Jl/N68ezN9vzSiqPTxw6vjcihoLPYKZHrlw==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [glibc] - '@biomejs/cli-linux-x64-musl@2.4.16': - resolution: {integrity: sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg==} + '@biomejs/cli-linux-x64-musl@2.5.0': + resolution: {integrity: sha512-+9hIcMngJ+yGUahXqZuZ8CoWKJE9SAZsFsM3QDvXpNsLbXZ9lqVzgBhOk/jTSYkOA0GLP9eu3teukqpLUojHMg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [musl] - '@biomejs/cli-linux-x64@2.4.16': - resolution: {integrity: sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ==} + '@biomejs/cli-linux-x64@2.5.0': + resolution: {integrity: sha512-zpEGf4RQbFEh8Vt7OmavLyyOzRbtcE9osCqrS1kfvt8jDvxwhKXLSf7n0ebr/ov0RJ9ssP+lhs6C8a9WwFvrQA==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [glibc] - '@biomejs/cli-win32-arm64@2.4.16': - resolution: {integrity: sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A==} + '@biomejs/cli-win32-arm64@2.5.0': + resolution: {integrity: sha512-jB0wAvTLI4itx5VidqVUejPQFhRUxiZ9l9FvZ26D5fl6t3qme+ZB4PD3bTSeL1vZ8NI2Rx/zj6H9zcESuGHKGw==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [win32] - '@biomejs/cli-win32-x64@2.4.16': - resolution: {integrity: sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw==} + '@biomejs/cli-win32-x64@2.5.0': + resolution: {integrity: sha512-VT/lF+GId+67j8aDfLkxdxNoVApsPSTbyAtB3jJq0IWTrY77WXfbPfpngxq0bA6JCEv/7k8C9qWjDRKRznDlyw==} engines: {node: '>=14.21.3'} cpu: [x64] os: [win32] - '@earendil-works/pi-agent-core@0.79.3': - resolution: {integrity: sha512-Ksvnu6CpQLYGbCSgnQEetzliI7yb+QkqtSlmmunJ69QluT45kd3DjQZRNHfRLk++Dd02Y8QvsRKMopSJCcWoWw==} + '@earendil-works/pi-agent-core@0.79.9': + resolution: {integrity: sha512-GsFbPR85nhncKoU9++fTKa11PzwUkAmkrKXo97dBOzi10Td72rVV6vmfxKjwPLHTzRbJMa0byr12YhODZ59yLA==} engines: {node: '>=22.19.0'} - '@earendil-works/pi-ai@0.79.3': - resolution: {integrity: sha512-lMSput/haP5uZAGbXhS5rAYd3GB7GYdJkoAUxg3VFummBeqGqGqllaTWrbHFN12kVGyVfWHhdySNXkiqVh65Iw==} + '@earendil-works/pi-ai@0.79.9': + resolution: {integrity: sha512-fHmgNMONwCCE7bQAKbcz76sgm3iQuA7km1mpIc4H5xXd9+zhPh/faULz6ARkgjQE0EufHnfZPJY39+lNf8Sa9g==} engines: {node: '>=22.19.0'} hasBin: true - '@earendil-works/pi-coding-agent@0.79.3': - resolution: {integrity: sha512-B3rAyw4/f1l3EYjQpCKpevzuBIPjYor6fHxPHQpLMdn/NTv3536i1P4ZrsyFkKpXqJWH66xKK5hyf8sqRe3dGA==} + '@earendil-works/pi-coding-agent@0.79.9': + resolution: {integrity: sha512-8TZ796Zn0NE4vmhxG9hv4ZtJDGJzhqMjlmFg8ZkUKxfqB7LJa4ums2jSJKtnyAZfAamN6VzqzN0A82RNDqv8Ag==} engines: {node: '>=22.19.0'} hasBin: true - '@earendil-works/pi-tui@0.79.3': - resolution: {integrity: sha512-cpmkEM1aEuGUx6YZM36VlzpulwLzqD5T2cUEkGHndDTNGEbnn5sj/9SYm+QBfKjvZsWoHfZuFBnu4+hh96/FbA==} + '@earendil-works/pi-tui@0.79.9': + resolution: {integrity: sha512-XcqfoGyoX64OSMQklMR1vG2MRi1TCPSUERRCmPDvYKCK6LtZoBqJ6idNCLRklNYveCj4gHDOzOsLhGqn04jmYw==} engines: {node: '>=22.19.0'} '@emnapi/core@1.10.0': @@ -307,8 +307,13 @@ packages: resolution: {integrity: sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==} engines: {node: '>= 10'} - '@mistralai/mistralai@2.2.1': - resolution: {integrity: sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==} + '@mistralai/mistralai@2.2.6': + resolution: {integrity: sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true '@napi-rs/wasm-runtime@1.1.4': resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} @@ -319,6 +324,14 @@ packages: '@nodable/entities@2.1.1': resolution: {integrity: sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==} + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/semantic-conventions@1.41.1': + resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==} + engines: {node: '>=14'} + '@oxc-project/types@0.132.0': resolution: {integrity: sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==} @@ -508,17 +521,17 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - '@types/node@25.9.1': - resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==} + '@types/node@25.9.4': + resolution: {integrity: sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g==} '@types/retry@0.12.0': resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} - '@vitest/expect@4.1.7': - resolution: {integrity: sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==} + '@vitest/expect@4.1.9': + resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} - '@vitest/mocker@4.1.7': - resolution: {integrity: sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==} + '@vitest/mocker@4.1.9': + resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -528,20 +541,20 @@ packages: vite: optional: true - '@vitest/pretty-format@4.1.7': - resolution: {integrity: sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==} + '@vitest/pretty-format@4.1.9': + resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==} - '@vitest/runner@4.1.7': - resolution: {integrity: sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==} + '@vitest/runner@4.1.9': + resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==} - '@vitest/snapshot@4.1.7': - resolution: {integrity: sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==} + '@vitest/snapshot@4.1.9': + resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==} - '@vitest/spy@4.1.7': - resolution: {integrity: sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==} + '@vitest/spy@4.1.9': + resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==} - '@vitest/utils@4.1.7': - resolution: {integrity: sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==} + '@vitest/utils@4.1.9': + resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} @@ -802,9 +815,9 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - marked@15.0.12: - resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==} - engines: {node: '>= 18'} + marked@18.0.5: + resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==} + engines: {node: '>= 20'} hasBin: true minimatch@10.2.5: @@ -903,6 +916,11 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + semver@7.8.0: + resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} + engines: {node: '>=10'} + hasBin: true + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -962,8 +980,8 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} - undici@8.3.0: - resolution: {integrity: sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==} + undici@8.5.0: + resolution: {integrity: sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==} engines: {node: '>=22.19.0'} vite@8.0.14: @@ -1009,20 +1027,20 @@ packages: yaml: optional: true - vitest@4.1.7: - resolution: {integrity: sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==} + vitest@4.1.9: + resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.7 - '@vitest/browser-preview': 4.1.7 - '@vitest/browser-webdriverio': 4.1.7 - '@vitest/coverage-istanbul': 4.1.7 - '@vitest/coverage-v8': 4.1.7 - '@vitest/ui': 4.1.7 + '@vitest/browser-playwright': 4.1.9 + '@vitest/browser-preview': 4.1.9 + '@vitest/browser-webdriverio': 4.1.9 + '@vitest/coverage-istanbul': 4.1.9 + '@vitest/coverage-v8': 4.1.9 + '@vitest/ui': 4.1.9 happy-dom: '*' jsdom: '*' vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -1326,44 +1344,44 @@ snapshots: '@babel/runtime@7.29.7': {} - '@biomejs/biome@2.4.16': + '@biomejs/biome@2.5.0': optionalDependencies: - '@biomejs/cli-darwin-arm64': 2.4.16 - '@biomejs/cli-darwin-x64': 2.4.16 - '@biomejs/cli-linux-arm64': 2.4.16 - '@biomejs/cli-linux-arm64-musl': 2.4.16 - '@biomejs/cli-linux-x64': 2.4.16 - '@biomejs/cli-linux-x64-musl': 2.4.16 - '@biomejs/cli-win32-arm64': 2.4.16 - '@biomejs/cli-win32-x64': 2.4.16 - - '@biomejs/cli-darwin-arm64@2.4.16': + '@biomejs/cli-darwin-arm64': 2.5.0 + '@biomejs/cli-darwin-x64': 2.5.0 + '@biomejs/cli-linux-arm64': 2.5.0 + '@biomejs/cli-linux-arm64-musl': 2.5.0 + '@biomejs/cli-linux-x64': 2.5.0 + '@biomejs/cli-linux-x64-musl': 2.5.0 + '@biomejs/cli-win32-arm64': 2.5.0 + '@biomejs/cli-win32-x64': 2.5.0 + + '@biomejs/cli-darwin-arm64@2.5.0': optional: true - '@biomejs/cli-darwin-x64@2.4.16': + '@biomejs/cli-darwin-x64@2.5.0': optional: true - '@biomejs/cli-linux-arm64-musl@2.4.16': + '@biomejs/cli-linux-arm64-musl@2.5.0': optional: true - '@biomejs/cli-linux-arm64@2.4.16': + '@biomejs/cli-linux-arm64@2.5.0': optional: true - '@biomejs/cli-linux-x64-musl@2.4.16': + '@biomejs/cli-linux-x64-musl@2.5.0': optional: true - '@biomejs/cli-linux-x64@2.4.16': + '@biomejs/cli-linux-x64@2.5.0': optional: true - '@biomejs/cli-win32-arm64@2.4.16': + '@biomejs/cli-win32-arm64@2.5.0': optional: true - '@biomejs/cli-win32-x64@2.4.16': + '@biomejs/cli-win32-x64@2.5.0': optional: true - '@earendil-works/pi-agent-core@0.79.3(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-agent-core@0.79.9(ws@8.21.0)(zod@4.4.3)': dependencies: - '@earendil-works/pi-ai': 0.79.3(ws@8.21.0)(zod@4.4.3) + '@earendil-works/pi-ai': 0.79.9(ws@8.21.0)(zod@4.4.3) ignore: 7.0.5 typebox: 1.1.38 yaml: 2.9.0 @@ -1375,12 +1393,13 @@ snapshots: - ws - zod - '@earendil-works/pi-ai@0.79.3(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-ai@0.79.9(ws@8.21.0)(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.91.1(zod@4.4.3) '@aws-sdk/client-bedrock-runtime': 3.1048.0 '@google/genai': 1.52.0 - '@mistralai/mistralai': 2.2.1 + '@mistralai/mistralai': 2.2.6(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.0 '@smithy/node-http-handler': 4.7.3 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 @@ -1395,11 +1414,11 @@ snapshots: - ws - zod - '@earendil-works/pi-coding-agent@0.79.3(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-coding-agent@0.79.9(ws@8.21.0)(zod@4.4.3)': dependencies: - '@earendil-works/pi-agent-core': 0.79.3(ws@8.21.0)(zod@4.4.3) - '@earendil-works/pi-ai': 0.79.3(ws@8.21.0)(zod@4.4.3) - '@earendil-works/pi-tui': 0.79.3 + '@earendil-works/pi-agent-core': 0.79.9(ws@8.21.0)(zod@4.4.3) + '@earendil-works/pi-ai': 0.79.9(ws@8.21.0)(zod@4.4.3) + '@earendil-works/pi-tui': 0.79.9 '@silvia-odwyer/photon-node': 0.3.4 chalk: 5.6.2 cross-spawn: 7.0.6 @@ -1411,8 +1430,9 @@ snapshots: jiti: 2.7.0 minimatch: 10.2.5 proper-lockfile: 4.1.2 + semver: 7.8.0 typebox: 1.1.38 - undici: 8.3.0 + undici: 8.5.0 yaml: 2.9.0 optionalDependencies: '@mariozechner/clipboard': 0.3.9 @@ -1424,10 +1444,10 @@ snapshots: - ws - zod - '@earendil-works/pi-tui@0.79.3': + '@earendil-works/pi-tui@0.79.9': dependencies: get-east-asian-width: 1.6.0 - marked: 15.0.12 + marked: 18.0.5 '@emnapi/core@1.10.0': dependencies: @@ -1502,11 +1522,14 @@ snapshots: '@mariozechner/clipboard-win32-x64-msvc': 0.3.9 optional: true - '@mistralai/mistralai@2.2.1': + '@mistralai/mistralai@2.2.6(@opentelemetry/api@1.9.0)': dependencies: + '@opentelemetry/semantic-conventions': 1.41.1 ws: 8.21.0 zod: 4.4.3 zod-to-json-schema: 3.25.2(zod@4.4.3) + optionalDependencies: + '@opentelemetry/api': 1.9.0 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -1520,6 +1543,10 @@ snapshots: '@nodable/entities@2.1.1': {} + '@opentelemetry/api@1.9.0': {} + + '@opentelemetry/semantic-conventions@1.41.1': {} + '@oxc-project/types@0.132.0': {} '@protobufjs/aspromise@1.1.2': {} @@ -1667,50 +1694,50 @@ snapshots: '@types/estree@1.0.9': {} - '@types/node@25.9.1': + '@types/node@25.9.4': dependencies: undici-types: 7.24.6 '@types/retry@0.12.0': {} - '@vitest/expect@4.1.7': + '@vitest/expect@4.1.9': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.1.7 - '@vitest/utils': 4.1.7 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.7(vite@8.0.14(@types/node@25.9.1)(jiti@2.7.0)(yaml@2.9.0))': + '@vitest/mocker@4.1.9(vite@8.0.14(@types/node@25.9.4)(jiti@2.7.0)(yaml@2.9.0))': dependencies: - '@vitest/spy': 4.1.7 + '@vitest/spy': 4.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.14(@types/node@25.9.1)(jiti@2.7.0)(yaml@2.9.0) + vite: 8.0.14(@types/node@25.9.4)(jiti@2.7.0)(yaml@2.9.0) - '@vitest/pretty-format@4.1.7': + '@vitest/pretty-format@4.1.9': dependencies: tinyrainbow: 3.1.0 - '@vitest/runner@4.1.7': + '@vitest/runner@4.1.9': dependencies: - '@vitest/utils': 4.1.7 + '@vitest/utils': 4.1.9 pathe: 2.0.3 - '@vitest/snapshot@4.1.7': + '@vitest/snapshot@4.1.9': dependencies: - '@vitest/pretty-format': 4.1.7 - '@vitest/utils': 4.1.7 + '@vitest/pretty-format': 4.1.9 + '@vitest/utils': 4.1.9 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.1.7': {} + '@vitest/spy@4.1.9': {} - '@vitest/utils@4.1.7': + '@vitest/utils@4.1.9': dependencies: - '@vitest/pretty-format': 4.1.7 + '@vitest/pretty-format': 4.1.9 convert-source-map: 2.0.0 tinyrainbow: 3.1.0 @@ -1938,7 +1965,7 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - marked@15.0.12: {} + marked@18.0.5: {} minimatch@10.2.5: dependencies: @@ -2011,7 +2038,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.1 - '@types/node': 25.9.1 + '@types/node': 25.9.4 long: 5.3.2 retry@0.12.0: {} @@ -2041,6 +2068,8 @@ snapshots: safe-buffer@5.2.1: {} + semver@7.8.0: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -2080,9 +2109,9 @@ snapshots: undici-types@7.24.6: {} - undici@8.3.0: {} + undici@8.5.0: {} - vite@8.0.14(@types/node@25.9.1)(jiti@2.7.0)(yaml@2.9.0): + vite@8.0.14(@types/node@25.9.4)(jiti@2.7.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -2090,20 +2119,20 @@ snapshots: rolldown: 1.0.2 tinyglobby: 0.2.16 optionalDependencies: - '@types/node': 25.9.1 + '@types/node': 25.9.4 fsevents: 2.3.3 jiti: 2.7.0 yaml: 2.9.0 - vitest@4.1.7(@types/node@25.9.1)(vite@8.0.14(@types/node@25.9.1)(jiti@2.7.0)(yaml@2.9.0)): + vitest@4.1.9(@opentelemetry/api@1.9.0)(@types/node@25.9.4)(vite@8.0.14(@types/node@25.9.4)(jiti@2.7.0)(yaml@2.9.0)): dependencies: - '@vitest/expect': 4.1.7 - '@vitest/mocker': 4.1.7(vite@8.0.14(@types/node@25.9.1)(jiti@2.7.0)(yaml@2.9.0)) - '@vitest/pretty-format': 4.1.7 - '@vitest/runner': 4.1.7 - '@vitest/snapshot': 4.1.7 - '@vitest/spy': 4.1.7 - '@vitest/utils': 4.1.7 + '@vitest/expect': 4.1.9 + '@vitest/mocker': 4.1.9(vite@8.0.14(@types/node@25.9.4)(jiti@2.7.0)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.9 + '@vitest/runner': 4.1.9 + '@vitest/snapshot': 4.1.9 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 es-module-lexer: 2.1.0 expect-type: 1.3.0 magic-string: 0.30.21 @@ -2115,10 +2144,11 @@ snapshots: tinyexec: 1.2.3 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 8.0.14(@types/node@25.9.1)(jiti@2.7.0)(yaml@2.9.0) + vite: 8.0.14(@types/node@25.9.4)(jiti@2.7.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 25.9.1 + '@opentelemetry/api': 1.9.0 + '@types/node': 25.9.4 transitivePeerDependencies: - msw diff --git a/tests/index.test.ts b/tests/index.test.ts index 34cadb5..5187218 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -165,11 +165,11 @@ describe("package config", () => { }); }); - it("requires Node 22.19 or newer", () => { + it("requires Node 24.15.0 or newer", () => { const pkg = JSON.parse(readFileSync("package.json", "utf8")) as { engines: { node: string }; }; - expect(pkg.engines.node).toBe(">=22.19"); + expect(pkg.engines.node).toBe(">=24.15.0"); }); it("documents shared export entrypoints", () => {