@fyeeme/pi-statusline 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/LICENSE +21 -0
- package/README.md +70 -0
- package/cache.ts +63 -0
- package/footer.ts +152 -0
- package/index.ts +258 -0
- package/package.json +63 -0
- package/providers/deepseek.ts +81 -0
- package/providers/types.ts +13 -0
- package/providers/zai.ts +178 -0
- package/quota/deepseek.ts +31 -0
- package/quota/types.ts +34 -0
- package/quota/zai.ts +90 -0
- package/session-scanner.ts +64 -0
- package/token-usage.ts +45 -0
- package/types.ts +43 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [1.0.0] - 2025-05-31
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Initial release
|
|
15
|
+
- Two-line footer: cwd + git branch (left) / model + thinking level (right), and token stats line
|
|
16
|
+
- Token usage: input, output, cache read/write, total per session
|
|
17
|
+
- Cost display with auto currency detection (¥ for DeepSeek, $ otherwise)
|
|
18
|
+
- DeepSeek account balance fetched on startup, cached 5 minutes
|
|
19
|
+
- Context window usage percentage and size
|
|
20
|
+
- Elapsed time and tokens/sec for last response
|
|
21
|
+
- MCP server connection count and tool count via `mcp:status` / `mcp:disconnect` events
|
|
22
|
+
- `/balance` command to force-refresh DeepSeek balance
|
|
23
|
+
- `/currency [auto|¥|$]` command to toggle cost currency
|
|
24
|
+
- `/status-debug` command to dump session stats to log file
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# pi-statusline
|
|
2
|
+
|
|
3
|
+
A rich custom status bar for [pi](https://pi.dev) that replaces the default footer.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Token usage**: input, output, cache read/write, total per session
|
|
8
|
+
- **Cost**: cumulative cost with currency auto-detection (¥ for DeepSeek, $ otherwise)
|
|
9
|
+
- **DeepSeek balance**: live account balance fetched on startup and cached for 5 minutes
|
|
10
|
+
- **Context window**: usage percentage and size
|
|
11
|
+
- **Timing**: elapsed time + tokens/sec for last response
|
|
12
|
+
- **MCP status**: connected server count and total tool count
|
|
13
|
+
- **Git branch**: current branch shown in cwd display
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
Requires the [pi](https://pi.dev) CLI.
|
|
18
|
+
|
|
19
|
+
### From npm (recommended)
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# Global (user) install — available in every project
|
|
23
|
+
pi install npm:@fyeeme/pi-statusline
|
|
24
|
+
|
|
25
|
+
# Project-local — written to .pi/settings.json, shareable with your team
|
|
26
|
+
pi install -l npm:@fyeeme/pi-statusline
|
|
27
|
+
|
|
28
|
+
# Pinned version — skipped by `pi update`
|
|
29
|
+
pi install npm:@fyeeme/pi-statusline@1.0.0
|
|
30
|
+
|
|
31
|
+
# Try it once without saving (current run only)
|
|
32
|
+
pi -e npm:@fyeeme/pi-statusline
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### From GitHub
|
|
36
|
+
|
|
37
|
+
Source: [`fyeeme/pi-packages`](https://github.com/fyeeme/pi-packages).
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# HTTPS shorthand
|
|
41
|
+
pi install git:github.com/fyeeme/pi-packages
|
|
42
|
+
# Pin to a tag or commit (skipped by `pi update`)
|
|
43
|
+
pi install git:github.com/fyeeme/pi-packages@v1.0.0
|
|
44
|
+
# Raw URL form
|
|
45
|
+
pi install https://github.com/fyeeme/pi-packages
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
See the Pi Packages guide on [pi.dev](https://pi.dev) for the full list of source types, scopes, and `pi update` behavior.
|
|
49
|
+
|
|
50
|
+
## Commands
|
|
51
|
+
|
|
52
|
+
| Command | Description |
|
|
53
|
+
|---|---|
|
|
54
|
+
| `/balance` | Force refresh DeepSeek account balance |
|
|
55
|
+
| `/currency [auto\|¥\|$]` | Toggle cost currency display |
|
|
56
|
+
| `/status-debug` | Dump session stats to `/tmp/pi-status-debug.log` |
|
|
57
|
+
|
|
58
|
+
## Status Bar Layout
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
~/projects/my-repo (main) deepseek-v3 · xhigh
|
|
62
|
+
in 12k, out 8k, cache 45k, total 65k · ¥0.12/50.00 · 12.3%/64k · 45s 38.2 tok/s · MCP:2(15)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Line 1: cwd + git branch (left) | model + thinking level (right)
|
|
66
|
+
Line 2: token stats · cost/balance · context · timing · MCP
|
|
67
|
+
|
|
68
|
+
## MCP Integration
|
|
69
|
+
|
|
70
|
+
When used with [pi-mcp-adapter](https://www.npmjs.com/package/pi-mcp-adapter), the status bar shows live MCP connection status via `mcp:status` and `mcp:disconnect` events.
|
package/cache.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ProviderUsageResult } from "./types.ts";
|
|
3
|
+
import type { UsageProvider } from "./providers/types.ts";
|
|
4
|
+
|
|
5
|
+
export type ProviderRegistry = Record<string, UsageProvider>;
|
|
6
|
+
|
|
7
|
+
interface CacheEntry {
|
|
8
|
+
result: ProviderUsageResult;
|
|
9
|
+
fetchedAt: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
13
|
+
|
|
14
|
+
let usageCache: CacheEntry | null = null;
|
|
15
|
+
|
|
16
|
+
export function resetUsageCache(): void {
|
|
17
|
+
usageCache = null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getUsageCache(): CacheEntry | null {
|
|
21
|
+
return usageCache;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function refreshUsage(
|
|
25
|
+
providers: ProviderRegistry,
|
|
26
|
+
modelRegistry: ExtensionContext["modelRegistry"],
|
|
27
|
+
model: ExtensionContext["model"],
|
|
28
|
+
): Promise<ProviderUsageResult> {
|
|
29
|
+
const providerName = model?.provider ?? "";
|
|
30
|
+
const provider = providers[providerName];
|
|
31
|
+
if (!provider) {
|
|
32
|
+
usageCache = null;
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const result = await provider.fetchUsage(modelRegistry, model);
|
|
37
|
+
if (result) {
|
|
38
|
+
usageCache = { result, fetchedAt: Date.now() };
|
|
39
|
+
} else {
|
|
40
|
+
usageCache = null;
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function getUsageCacheAge(): number | null {
|
|
46
|
+
if (!usageCache) return null;
|
|
47
|
+
return Date.now() - usageCache.fetchedAt;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getCachedUsage(
|
|
51
|
+
providers: ProviderRegistry,
|
|
52
|
+
lastCtx: ExtensionContext | null,
|
|
53
|
+
lastModel: ExtensionContext["model"] | undefined,
|
|
54
|
+
): ProviderUsageResult {
|
|
55
|
+
if (!usageCache) return null;
|
|
56
|
+
if (Date.now() - usageCache.fetchedAt > CACHE_TTL_MS) {
|
|
57
|
+
// TTL expired: return stale data but trigger background refresh
|
|
58
|
+
if (lastCtx && lastModel) {
|
|
59
|
+
void refreshUsage(providers, lastCtx.modelRegistry, lastModel);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return usageCache.result;
|
|
63
|
+
}
|
package/footer.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
2
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { ContextUsage } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import type { ProviderUsageResult } from "./types.ts";
|
|
5
|
+
import type { TokenUsage } from "./types.ts";
|
|
6
|
+
import type { UsageProvider } from "./providers/types.ts";
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Formatting helpers
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
export function fmt(n: number): string {
|
|
13
|
+
if (n < 1000) return n.toString();
|
|
14
|
+
if (n < 10000) return `${(n / 1000).toFixed(1)}k`;
|
|
15
|
+
if (n < 1000000) return `${Math.round(n / 1000)}k`;
|
|
16
|
+
if (n < 10000000) return `${(n / 1000000).toFixed(1)}M`;
|
|
17
|
+
return `${Math.round(n / 1000000)}M`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function formatDuration(sec: number): string {
|
|
21
|
+
if (sec < 60) return `${Math.floor(sec)}s`;
|
|
22
|
+
const min = Math.floor(sec / 60);
|
|
23
|
+
const s = Math.floor(sec % 60);
|
|
24
|
+
return `${min}m${s}s`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Format countdown from epoch ms to XhYm string. */
|
|
28
|
+
export function formatCountdown(resetAt: number): string {
|
|
29
|
+
if (!resetAt) return "?";
|
|
30
|
+
const remainingMs = resetAt - Date.now();
|
|
31
|
+
if (remainingMs <= 0) return "0m";
|
|
32
|
+
const totalMin = Math.floor(remainingMs / 60000);
|
|
33
|
+
const h = Math.floor(totalMin / 60);
|
|
34
|
+
const m = totalMin % 60;
|
|
35
|
+
if (h > 0) return `${h}h${m}m`;
|
|
36
|
+
return `${m}m`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Format countdown with day precision for weekly cycles. */
|
|
40
|
+
export function formatWeeklyCountdown(resetAt: number): string {
|
|
41
|
+
if (!resetAt) return "?";
|
|
42
|
+
const remainingMs = resetAt - Date.now();
|
|
43
|
+
if (remainingMs <= 0) return "0h";
|
|
44
|
+
const totalMin = Math.floor(remainingMs / 60000);
|
|
45
|
+
const d = Math.floor(totalMin / 1440);
|
|
46
|
+
const h = Math.floor((totalMin % 1440) / 60);
|
|
47
|
+
if (d > 0) return `${d}d${h}h`;
|
|
48
|
+
const m = totalMin % 60;
|
|
49
|
+
if (h > 0) return `${h}h${m}m`;
|
|
50
|
+
return `${m}m`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function formatCwd(cwd: string): string {
|
|
54
|
+
const home = process.env.HOME || process.env.USERPROFILE;
|
|
55
|
+
if (!home) return cwd;
|
|
56
|
+
const resolvedCwd = resolve(cwd);
|
|
57
|
+
const resolvedHome = resolve(home);
|
|
58
|
+
const rel = relative(resolvedHome, resolvedCwd);
|
|
59
|
+
const insideHome =
|
|
60
|
+
rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
61
|
+
if (!insideHome) return cwd;
|
|
62
|
+
return rel === "" ? "~" : `~${sep}${rel}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export { truncateToWidth, visibleWidth };
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Footer line builders
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
export function buildStatLine(
|
|
72
|
+
stats: TokenUsage & { cost: number; currency: "¥" | "$" },
|
|
73
|
+
contextUsage: ContextUsage | undefined,
|
|
74
|
+
providerResult: ProviderUsageResult,
|
|
75
|
+
providers: Record<string, UsageProvider>,
|
|
76
|
+
getElapsedSec: () => number,
|
|
77
|
+
lastTps: number,
|
|
78
|
+
mcpStatuses: Map<string, { connected: boolean; toolCount: number }>,
|
|
79
|
+
): string {
|
|
80
|
+
const mods: string[] = [];
|
|
81
|
+
|
|
82
|
+
// tokens 566k(in 29k, out 22k, cache 515k,45.2%)
|
|
83
|
+
{
|
|
84
|
+
const tok: string[] = [];
|
|
85
|
+
if (stats.input) tok.push(`in ${fmt(stats.input)}`);
|
|
86
|
+
if (stats.output) tok.push(`out ${fmt(stats.output)}`);
|
|
87
|
+
const cache = stats.cacheRead + stats.cacheWrite;
|
|
88
|
+
if (cache) {
|
|
89
|
+
const cacheStr = stats.hitRate > 0
|
|
90
|
+
? `cache ${fmt(cache)},${(stats.hitRate * 100).toFixed(1)}%`
|
|
91
|
+
: `cache ${fmt(cache)}`;
|
|
92
|
+
tok.push(cacheStr);
|
|
93
|
+
}
|
|
94
|
+
if (tok.length > 0 && stats.total) {
|
|
95
|
+
mods.push(`tokens ${fmt(stats.total)}(${tok.join(", ")})`);
|
|
96
|
+
} else if (tok.length > 0) {
|
|
97
|
+
mods.push(tok.join(", "));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Provider-specific cost/usage
|
|
102
|
+
if (providerResult) {
|
|
103
|
+
const providerName = providerResult.provider;
|
|
104
|
+
const provider = providers[providerName];
|
|
105
|
+
if (provider) {
|
|
106
|
+
const formatted = provider.formatForFooter(providerResult, stats.cost, stats.currency);
|
|
107
|
+
if (formatted) mods.push(formatted);
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
if (stats.cost > 0) {
|
|
111
|
+
mods.push(`${stats.currency}${stats.cost.toFixed(2)}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// context: 45.2%/16k or ?/16k
|
|
116
|
+
if (contextUsage) {
|
|
117
|
+
const w = fmt(contextUsage.contextWindow);
|
|
118
|
+
mods.push(
|
|
119
|
+
contextUsage.percent !== null
|
|
120
|
+
? `${contextUsage.percent.toFixed(1)}%/${w}`
|
|
121
|
+
: `?/${w}`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// timing: 2m30s 39.5 tok/s
|
|
126
|
+
{
|
|
127
|
+
const t: string[] = [];
|
|
128
|
+
const elapsed = getElapsedSec();
|
|
129
|
+
if (elapsed > 0) t.push(formatDuration(elapsed));
|
|
130
|
+
if (lastTps > 0) t.push(`${lastTps.toFixed(1)} tok/s`);
|
|
131
|
+
if (t.length) mods.push(t.join(" "));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// MCP: MCP:2(15) or MCP:0
|
|
135
|
+
{
|
|
136
|
+
const entries = Array.from(mcpStatuses.entries());
|
|
137
|
+
if (entries.length) {
|
|
138
|
+
const connected = entries.filter(([, s]) => s.connected).length;
|
|
139
|
+
const tools = entries.reduce((sum, [, s]) => sum + s.toolCount, 0);
|
|
140
|
+
mods.push(connected > 0 ? `MCP:${connected}(${tools})` : "MCP:0");
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return mods.join(" · ");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function buildInfoLine(modelId: string | undefined, thinkingLevel: string): string {
|
|
148
|
+
const parts: string[] = [];
|
|
149
|
+
if (modelId) parts.push(modelId);
|
|
150
|
+
if (thinkingLevel && thinkingLevel !== "off") parts.push(thinkingLevel);
|
|
151
|
+
return parts.join(" · ");
|
|
152
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Status Line Extension v3
|
|
3
|
+
*
|
|
4
|
+
* Replaces the default footer with a custom status line.
|
|
5
|
+
* Uses a strategy pattern to show provider-aware usage data:
|
|
6
|
+
* - DeepSeek: balance + weekly tokens (from session files)
|
|
7
|
+
* - ZAI/GLM: rolling quota (5h + MCP) + weekly tokens (from API)
|
|
8
|
+
* - Others: session-scoped cost only
|
|
9
|
+
*
|
|
10
|
+
* Commands:
|
|
11
|
+
* /status-debug - dump session stats to /tmp/pi-status-debug.log
|
|
12
|
+
* /currency - toggle ¥ / $ / auto
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { appendFileSync } from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
18
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
20
|
+
|
|
21
|
+
import { DeepSeekUsageProvider } from "./providers/deepseek.ts";
|
|
22
|
+
import { ZaiUsageProvider } from "./providers/zai.ts";
|
|
23
|
+
import type { UsageProvider } from "./providers/types.ts";
|
|
24
|
+
import { SessionTokenUsageCalculator } from "./token-usage.ts";
|
|
25
|
+
import { refreshUsage, getCachedUsage, getUsageCacheAge } from "./cache.ts";
|
|
26
|
+
import { formatCwd, buildStatLine, buildInfoLine } from "./footer.ts";
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Provider registry
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
const providers: Record<string, UsageProvider> = {
|
|
33
|
+
deepseek: new DeepSeekUsageProvider(),
|
|
34
|
+
zai: new ZaiUsageProvider(),
|
|
35
|
+
"zai-coding-cn": new ZaiUsageProvider(),
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Runtime state
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
let agentStartMs: number | null = null;
|
|
43
|
+
let lastCtx: ExtensionContext | null = null;
|
|
44
|
+
let lastModel: ExtensionContext["model"] = undefined;
|
|
45
|
+
let agentRunning = false;
|
|
46
|
+
let lastElapsedSec = 0;
|
|
47
|
+
let lastTps = 0;
|
|
48
|
+
let currencyOverride: "¥" | "$" | undefined = undefined;
|
|
49
|
+
|
|
50
|
+
const mcpStatuses = new Map<string, { connected: boolean; toolCount: number }>();
|
|
51
|
+
|
|
52
|
+
const tokenCalculator = new SessionTokenUsageCalculator(() => currencyOverride);
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Helpers
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
function getElapsedSec(): number {
|
|
59
|
+
if (agentRunning && agentStartMs !== null) return (Date.now() - agentStartMs) / 1000;
|
|
60
|
+
return lastElapsedSec;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Extension
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
export default function (pi: ExtensionAPI) {
|
|
68
|
+
pi.on("agent_start", () => {
|
|
69
|
+
agentStartMs = Date.now();
|
|
70
|
+
agentRunning = true;
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
pi.on("agent_end", (event) => {
|
|
74
|
+
agentRunning = false;
|
|
75
|
+
if (agentStartMs === null) return;
|
|
76
|
+
const elapsedMs = Date.now() - agentStartMs;
|
|
77
|
+
if (elapsedMs <= 0) return;
|
|
78
|
+
lastElapsedSec += elapsedMs / 1000;
|
|
79
|
+
|
|
80
|
+
// Compute cumulative output tokens from all assistant messages in the session
|
|
81
|
+
let cumulativeOutput = 0;
|
|
82
|
+
if (lastCtx) {
|
|
83
|
+
for (const entry of lastCtx.sessionManager.getEntries()) {
|
|
84
|
+
if (entry.type === "message" && entry.message.role === "assistant") {
|
|
85
|
+
cumulativeOutput += (entry.message as AssistantMessage).usage.output;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
// fallback: use current turn's messages when no session context
|
|
90
|
+
for (const m of event.messages) {
|
|
91
|
+
if (m.role === "assistant") cumulativeOutput += (m as AssistantMessage).usage.output;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
lastTps = cumulativeOutput > 0 ? cumulativeOutput / lastElapsedSec : 0;
|
|
95
|
+
|
|
96
|
+
// Schedule background refresh to pick up new usage data
|
|
97
|
+
if (lastCtx && lastModel) {
|
|
98
|
+
void refreshUsage(providers, lastCtx.modelRegistry, lastModel);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
pi.events.on("mcp:status", (raw) => {
|
|
103
|
+
const data = raw as { id: string; connected: boolean; toolCount: number };
|
|
104
|
+
mcpStatuses.set(data.id, { connected: data.connected, toolCount: data.toolCount });
|
|
105
|
+
});
|
|
106
|
+
pi.events.on("mcp:disconnect", (raw) => {
|
|
107
|
+
mcpStatuses.delete((raw as { id: string }).id);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ---- /status-debug ----
|
|
111
|
+
pi.registerCommand("status-debug", {
|
|
112
|
+
description: "Dump session stats to /tmp/pi-status-debug.log + console.error",
|
|
113
|
+
handler: async (_args, ctx) => {
|
|
114
|
+
const logPath = `${tmpdir()}/pi-status-debug.log`;
|
|
115
|
+
const w = (s: string) => {
|
|
116
|
+
console.error(s);
|
|
117
|
+
try { appendFileSync(logPath, s + "\n"); } catch { /* ok */ }
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
w("=== STATUS-LINE DEBUG ===");
|
|
121
|
+
w(`cwd: ${ctx.cwd}`);
|
|
122
|
+
w(`model: ${ctx.model?.provider}/${ctx.model?.id}`);
|
|
123
|
+
w(`thinking: ${pi.getThinkingLevel()}`);
|
|
124
|
+
|
|
125
|
+
const cu = ctx.getContextUsage();
|
|
126
|
+
w(`contextUsage: tokens=${cu?.tokens ?? "?"} window=${cu?.contextWindow ?? "?"} percent=${cu?.percent ?? "?"}`);
|
|
127
|
+
|
|
128
|
+
// Usage cache dump
|
|
129
|
+
w(`--- provider usage ---`);
|
|
130
|
+
const cached = getCachedUsage(providers, lastCtx, lastModel);
|
|
131
|
+
if (cached) {
|
|
132
|
+
w(` provider: ${cached.provider}`);
|
|
133
|
+
const dbgProvider = providers[cached.provider];
|
|
134
|
+
if (dbgProvider) dbgProvider.debugDump(cached, w);
|
|
135
|
+
} else {
|
|
136
|
+
w(` (no cached usage data)`);
|
|
137
|
+
}
|
|
138
|
+
const cacheAge = getUsageCacheAge();
|
|
139
|
+
w(` cacheAge: ${cacheAge !== null ? `${Math.round(cacheAge / 1000)}s` : "N/A"}`);
|
|
140
|
+
|
|
141
|
+
let idx = 0;
|
|
142
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
143
|
+
if (entry.type === "message" && entry.message.role === "assistant") {
|
|
144
|
+
const m = entry.message as AssistantMessage;
|
|
145
|
+
w(`--- msg[${idx}] ---`);
|
|
146
|
+
w(` usage: ${JSON.stringify(m.usage)}`);
|
|
147
|
+
w(` model: ${m.model}`);
|
|
148
|
+
w(` cost: ${JSON.stringify(m.usage.cost)}`);
|
|
149
|
+
idx++;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const stats = tokenCalculator.compute(ctx);
|
|
154
|
+
w(`--- computed ---`);
|
|
155
|
+
w(` input=${stats.input} output=${stats.output}`);
|
|
156
|
+
w(` cacheRead=${stats.cacheRead} cacheWrite=${stats.cacheWrite}`);
|
|
157
|
+
w(` total=${stats.total} cost=${stats.cost} currency=${stats.currency}`);
|
|
158
|
+
w(` hitRate=${(stats.hitRate * 100).toFixed(1)}%`);
|
|
159
|
+
w("=========================");
|
|
160
|
+
|
|
161
|
+
ctx.ui.notify(`Debug written to ${logPath} (${idx} msgs)`, "info");
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// ---- /currency ----
|
|
166
|
+
pi.registerCommand("currency", {
|
|
167
|
+
description: "Toggle cost currency (auto / ¥ / $)",
|
|
168
|
+
handler: async (args, ctx) => {
|
|
169
|
+
const a = args.trim().toLowerCase();
|
|
170
|
+
if (a === "auto" || a === "") {
|
|
171
|
+
currencyOverride = undefined;
|
|
172
|
+
ctx.ui.notify("Currency: auto (deepseek→¥)", "info");
|
|
173
|
+
} else if (a === "¥" || a === "rmb" || a === "cny") {
|
|
174
|
+
currencyOverride = "¥";
|
|
175
|
+
ctx.ui.notify("Currency: ¥", "info");
|
|
176
|
+
} else if (a === "$" || a === "usd") {
|
|
177
|
+
currencyOverride = "$";
|
|
178
|
+
ctx.ui.notify("Currency: $", "info");
|
|
179
|
+
} else {
|
|
180
|
+
ctx.ui.notify("Usage: /currency [auto|¥|$]", "warning");
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// ---- Model switch: refresh usage when provider changes ----
|
|
186
|
+
pi.on("model_select", (event) => {
|
|
187
|
+
if (lastCtx) {
|
|
188
|
+
lastModel = event.model;
|
|
189
|
+
void refreshUsage(providers, lastCtx.modelRegistry, event.model);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// ---- Footer ----
|
|
194
|
+
pi.on("session_start", (_event, ctx) => {
|
|
195
|
+
if (!ctx.hasUI) return;
|
|
196
|
+
|
|
197
|
+
// Reset cumulative timing for new session
|
|
198
|
+
lastElapsedSec = 0;
|
|
199
|
+
lastTps = 0;
|
|
200
|
+
|
|
201
|
+
// Prime usage cache on startup (non-blocking)
|
|
202
|
+
lastCtx = ctx;
|
|
203
|
+
lastModel = ctx.model;
|
|
204
|
+
void refreshUsage(providers, ctx.modelRegistry, ctx.model);
|
|
205
|
+
|
|
206
|
+
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
207
|
+
const unsub = footerData.onBranchChange(() => tui.requestRender());
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
dispose: unsub,
|
|
211
|
+
invalidate() {},
|
|
212
|
+
render(width: number): string[] {
|
|
213
|
+
const stats = tokenCalculator.compute(ctx);
|
|
214
|
+
const cu = ctx.getContextUsage();
|
|
215
|
+
const model = ctx.model;
|
|
216
|
+
const level = pi.getThinkingLevel();
|
|
217
|
+
const providerResult = getCachedUsage(providers, lastCtx, lastModel);
|
|
218
|
+
const dim = (s: string) => theme.fg("dim", s);
|
|
219
|
+
const lines: string[] = [];
|
|
220
|
+
|
|
221
|
+
// Line 0: cwd + git branch (left) | model · thinking (right)
|
|
222
|
+
let pwd = formatCwd(ctx.cwd);
|
|
223
|
+
const branch = footerData.getGitBranch();
|
|
224
|
+
if (branch) pwd = `${pwd} (${branch})`;
|
|
225
|
+
const left = dim(pwd);
|
|
226
|
+
const right = dim(buildInfoLine(model?.id, level));
|
|
227
|
+
const leftW = visibleWidth(left);
|
|
228
|
+
const rightW = visibleWidth(right);
|
|
229
|
+
if (leftW + rightW + 2 <= width) {
|
|
230
|
+
const pad = " ".repeat(width - leftW - rightW);
|
|
231
|
+
lines.push(left + pad + right);
|
|
232
|
+
} else {
|
|
233
|
+
lines.push(truncateToWidth(left + " " + right, width, dim("...")));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Line 1: tokens + cost + provider usage + context + elapsed + tps + mcp
|
|
237
|
+
lines.push(truncateToWidth(
|
|
238
|
+
dim(buildStatLine(stats, cu, providerResult, providers, getElapsedSec, lastTps, mcpStatuses)),
|
|
239
|
+
width,
|
|
240
|
+
dim("..."),
|
|
241
|
+
));
|
|
242
|
+
|
|
243
|
+
// Line 2: extension statuses
|
|
244
|
+
const extStatuses = footerData.getExtensionStatuses();
|
|
245
|
+
if (extStatuses.size > 0) {
|
|
246
|
+
const text = Array.from(extStatuses.entries())
|
|
247
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
248
|
+
.map(([, t]) => t.replace(/[\r\n\t]/g, " ").replace(/ +/g, " ").trim())
|
|
249
|
+
.join(" ");
|
|
250
|
+
lines.push(truncateToWidth(text, width, dim("...")));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return lines;
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fyeeme/pi-statusline",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Rich custom status bar for pi with token usage, cost, DeepSeek balance, context window, timing, and MCP connection status.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "fyeeme",
|
|
8
|
+
"publishConfig": {
|
|
9
|
+
"access": "public"
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "https://github.com/fyeeme/pi-packages"
|
|
14
|
+
},
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/fyeeme/pi-packages/issues"
|
|
17
|
+
},
|
|
18
|
+
"homepage": "https://github.com/fyeeme/pi-packages#readme",
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=18"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"pi-package",
|
|
24
|
+
"pi",
|
|
25
|
+
"status-line",
|
|
26
|
+
"statusline",
|
|
27
|
+
"deepseek",
|
|
28
|
+
"tokens",
|
|
29
|
+
"cost",
|
|
30
|
+
"mcp",
|
|
31
|
+
"balance",
|
|
32
|
+
"footer"
|
|
33
|
+
],
|
|
34
|
+
"files": [
|
|
35
|
+
"*.ts",
|
|
36
|
+
"providers/*.ts",
|
|
37
|
+
"quota/*.ts",
|
|
38
|
+
"README.md",
|
|
39
|
+
"LICENSE",
|
|
40
|
+
"CHANGELOG.md"
|
|
41
|
+
],
|
|
42
|
+
"pi": {
|
|
43
|
+
"extensions": [
|
|
44
|
+
"./index.ts"
|
|
45
|
+
]
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"test": "vitest --run",
|
|
49
|
+
"typecheck": "tsc"
|
|
50
|
+
},
|
|
51
|
+
"peerDependencies": {
|
|
52
|
+
"@earendil-works/pi-coding-agent": ">=0.77.0",
|
|
53
|
+
"@earendil-works/pi-ai": ">=0.77.0",
|
|
54
|
+
"@earendil-works/pi-tui": ">=0.77.0"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@earendil-works/pi-coding-agent": "0.77.0",
|
|
58
|
+
"@earendil-works/pi-ai": "0.77.0",
|
|
59
|
+
"@earendil-works/pi-tui": "0.77.0",
|
|
60
|
+
"@types/node": "22.19.19",
|
|
61
|
+
"typescript": "5.9.3"
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { DeepSeekResult, ProviderUsageResult } from "../types.ts";
|
|
3
|
+
import type { UsageProvider } from "./types.ts";
|
|
4
|
+
import type { QuotaCalculator } from "../quota/types.ts";
|
|
5
|
+
import { DeepSeekQuotaCalculator } from "../quota/deepseek.ts";
|
|
6
|
+
import { scanWeeklyTokens } from "../session-scanner.ts";
|
|
7
|
+
import { fmt } from "../footer.ts";
|
|
8
|
+
|
|
9
|
+
export class DeepSeekUsageProvider implements UsageProvider {
|
|
10
|
+
quotaCalculator: QuotaCalculator = new DeepSeekQuotaCalculator();
|
|
11
|
+
|
|
12
|
+
async fetchUsage(
|
|
13
|
+
modelRegistry: ExtensionContext["modelRegistry"],
|
|
14
|
+
model: ExtensionContext["model"],
|
|
15
|
+
): Promise<ProviderUsageResult> {
|
|
16
|
+
if (!model || model.provider !== "deepseek") return null;
|
|
17
|
+
|
|
18
|
+
const apiKey = await modelRegistry.getApiKeyForProvider("deepseek");
|
|
19
|
+
if (!apiKey) return null;
|
|
20
|
+
|
|
21
|
+
let origin = "https://api.deepseek.com";
|
|
22
|
+
try {
|
|
23
|
+
const u = new URL(model.baseUrl ?? "https://api.deepseek.com");
|
|
24
|
+
origin = u.origin;
|
|
25
|
+
} catch { /* use default */ }
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const res = await fetch(`${origin}/user/balance`, {
|
|
29
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
30
|
+
signal: AbortSignal.timeout(5000),
|
|
31
|
+
});
|
|
32
|
+
if (!res.ok) return null;
|
|
33
|
+
|
|
34
|
+
const data = (await res.json()) as {
|
|
35
|
+
is_available?: boolean;
|
|
36
|
+
balance_infos?: Array<{
|
|
37
|
+
currency?: string;
|
|
38
|
+
total_balance?: string;
|
|
39
|
+
}>;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const info = data.balance_infos?.[0];
|
|
43
|
+
if (!info) return null;
|
|
44
|
+
|
|
45
|
+
const weeklyTokens = scanWeeklyTokens("deepseek");
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
provider: "deepseek",
|
|
49
|
+
totalBalance: info.total_balance ?? "?",
|
|
50
|
+
currency: info.currency ?? "CNY",
|
|
51
|
+
weeklyTokens,
|
|
52
|
+
};
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
formatForFooter(result: NonNullable<ProviderUsageResult>, sessionCost: number, currency: string): string {
|
|
59
|
+
if (result.provider !== "deepseek") return "";
|
|
60
|
+
const ds = result as DeepSeekResult;
|
|
61
|
+
const balance = `${ds.currency === "CNY" ? "¥" : "$"}${ds.totalBalance}`;
|
|
62
|
+
const weekly = ds.weeklyTokens > 0 ? `7d:${fmt(ds.weeklyTokens)}` : "";
|
|
63
|
+
const parts: string[] = [];
|
|
64
|
+
|
|
65
|
+
if (sessionCost > 0) {
|
|
66
|
+
parts.push(`${currency}${sessionCost.toFixed(2)}/${balance}`);
|
|
67
|
+
} else {
|
|
68
|
+
parts.push(balance);
|
|
69
|
+
}
|
|
70
|
+
if (weekly) parts.push(weekly);
|
|
71
|
+
|
|
72
|
+
return parts.join(" · ");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
debugDump(result: NonNullable<ProviderUsageResult>, w: (s: string) => void): void {
|
|
76
|
+
if (result.provider !== "deepseek") return;
|
|
77
|
+
const ds = result as DeepSeekResult;
|
|
78
|
+
w(` balance: ${ds.currency} ${ds.totalBalance}`);
|
|
79
|
+
w(` weeklyTokens: ${ds.weeklyTokens}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ProviderUsageResult } from "../types.ts";
|
|
3
|
+
import type { QuotaCalculator } from "../quota/types.ts";
|
|
4
|
+
|
|
5
|
+
export interface UsageProvider {
|
|
6
|
+
fetchUsage(
|
|
7
|
+
modelRegistry: ExtensionContext["modelRegistry"],
|
|
8
|
+
model: ExtensionContext["model"],
|
|
9
|
+
): Promise<ProviderUsageResult>;
|
|
10
|
+
formatForFooter(result: NonNullable<ProviderUsageResult>, sessionCost: number, currency: string): string;
|
|
11
|
+
debugDump(result: NonNullable<ProviderUsageResult>, w: (s: string) => void): void;
|
|
12
|
+
quotaCalculator: QuotaCalculator;
|
|
13
|
+
}
|
package/providers/zai.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ZaiResult, ProviderUsageResult } from "../types.ts";
|
|
3
|
+
import type { UsageProvider } from "./types.ts";
|
|
4
|
+
import type { QuotaCalculator } from "../quota/types.ts";
|
|
5
|
+
import { ZaiQuotaCalculator } from "../quota/zai.ts";
|
|
6
|
+
import { fmt, formatCountdown, formatWeeklyCountdown } from "../footer.ts";
|
|
7
|
+
import { scanWeeklyTokens } from "../session-scanner.ts";
|
|
8
|
+
|
|
9
|
+
/** Check if the provider name is a ZAI/GLM variant. */
|
|
10
|
+
function isZaiProvider(provider: string): boolean {
|
|
11
|
+
return provider === "zai" || provider === "zai-coding-cn";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class ZaiUsageProvider implements UsageProvider {
|
|
15
|
+
quotaCalculator: QuotaCalculator = new ZaiQuotaCalculator();
|
|
16
|
+
|
|
17
|
+
async fetchUsage(
|
|
18
|
+
modelRegistry: ExtensionContext["modelRegistry"],
|
|
19
|
+
model: ExtensionContext["model"],
|
|
20
|
+
): Promise<ProviderUsageResult> {
|
|
21
|
+
if (!model || !isZaiProvider(model.provider)) return null;
|
|
22
|
+
|
|
23
|
+
const apiKey = await modelRegistry.getApiKeyForProvider(model.provider);
|
|
24
|
+
if (!apiKey) return null;
|
|
25
|
+
|
|
26
|
+
const defaultOrigin = model.provider === "zai-coding-cn"
|
|
27
|
+
? "https://open.bigmodel.cn"
|
|
28
|
+
: "https://api.z.ai";
|
|
29
|
+
let origin = defaultOrigin;
|
|
30
|
+
try {
|
|
31
|
+
origin = new URL(model.baseUrl ?? defaultOrigin).origin;
|
|
32
|
+
} catch { /* use default */ }
|
|
33
|
+
|
|
34
|
+
const headers: Record<string, string> = {
|
|
35
|
+
Authorization: apiKey,
|
|
36
|
+
"Accept-Language": "en-US,en",
|
|
37
|
+
"Content-Type": "application/json",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
// Fetch quota limits
|
|
42
|
+
const quotaRes = await fetch(`${origin}/api/monitor/usage/quota/limit`, {
|
|
43
|
+
headers,
|
|
44
|
+
signal: AbortSignal.timeout(5000),
|
|
45
|
+
});
|
|
46
|
+
if (!quotaRes.ok) return null;
|
|
47
|
+
|
|
48
|
+
const quotaData = (await quotaRes.json()) as {
|
|
49
|
+
data?: {
|
|
50
|
+
limits?: Array<{
|
|
51
|
+
type: string;
|
|
52
|
+
unit?: number;
|
|
53
|
+
percentage?: number;
|
|
54
|
+
nextResetTime?: number;
|
|
55
|
+
}>;
|
|
56
|
+
level?: string;
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const limits = quotaData.data?.limits ?? [];
|
|
61
|
+
const fiveHourLimit = limits.find((l) => l.type === "TOKENS_LIMIT" && l.unit === 3);
|
|
62
|
+
const weeklyLimit = limits.find((l) => l.type === "TOKENS_LIMIT" && l.unit === 6);
|
|
63
|
+
const level = quotaData.data?.level ?? "";
|
|
64
|
+
|
|
65
|
+
const now = Date.now();
|
|
66
|
+
let weeklyTokens = 0;
|
|
67
|
+
let weeklyResetAt = 0;
|
|
68
|
+
let weeklyPct = 0;
|
|
69
|
+
let isNaturalWeek = false;
|
|
70
|
+
|
|
71
|
+
if (weeklyLimit && weeklyLimit.nextResetTime && weeklyLimit.nextResetTime > 0) {
|
|
72
|
+
// unit:6 TOKENS_LIMIT exists — use API percentage + model-usage tokens
|
|
73
|
+
weeklyPct = weeklyLimit.percentage ?? 0;
|
|
74
|
+
weeklyResetAt = weeklyLimit.nextResetTime;
|
|
75
|
+
const cycleStart = weeklyResetAt - 7 * 24 * 60 * 60 * 1000;
|
|
76
|
+
weeklyTokens = await this.fetchCycleUsage(origin, headers, cycleStart, now);
|
|
77
|
+
} else {
|
|
78
|
+
// No unit:6 — natural week fallback, same as DeepSeek
|
|
79
|
+
isNaturalWeek = true;
|
|
80
|
+
weeklyTokens = scanWeeklyTokens(model.provider);
|
|
81
|
+
|
|
82
|
+
const d = new Date(now);
|
|
83
|
+
const dayOfWeek = d.getUTCDay();
|
|
84
|
+
const daysUntilMonday = dayOfWeek === 0 ? 1 : 8 - dayOfWeek;
|
|
85
|
+
weeklyResetAt = Date.UTC(
|
|
86
|
+
d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + daysUntilMonday, 0, 0, 0, 0,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
provider: "zai",
|
|
92
|
+
tokensLimitPct: fiveHourLimit?.percentage ?? 0,
|
|
93
|
+
tokensResetAt: fiveHourLimit?.nextResetTime ?? 0,
|
|
94
|
+
level,
|
|
95
|
+
weeklyTokens,
|
|
96
|
+
weeklyResetAt,
|
|
97
|
+
weeklyPct,
|
|
98
|
+
isNaturalWeek,
|
|
99
|
+
};
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
formatForFooter(result: NonNullable<ProviderUsageResult>, _sessionCost: number, _currency: string): string {
|
|
106
|
+
if (result.provider !== "zai") return "";
|
|
107
|
+
const zai = result as ZaiResult;
|
|
108
|
+
const parts: string[] = [];
|
|
109
|
+
|
|
110
|
+
// 5-hour rolling quota
|
|
111
|
+
const countdown = formatCountdown(zai.tokensResetAt);
|
|
112
|
+
parts.push(`Usage ${zai.tokensLimitPct}%(${countdown})`);
|
|
113
|
+
|
|
114
|
+
// Weekly quota
|
|
115
|
+
if (zai.isNaturalWeek) {
|
|
116
|
+
if (zai.weeklyTokens > 0) {
|
|
117
|
+
parts.push(`7d:${fmt(zai.weeklyTokens)}`);
|
|
118
|
+
}
|
|
119
|
+
} else if (zai.weeklyTokens > 0 || zai.weeklyPct > 0) {
|
|
120
|
+
const weeklyCountdown = formatWeeklyCountdown(zai.weeklyResetAt);
|
|
121
|
+
parts.push(`W:${zai.weeklyPct}%(${fmt(zai.weeklyTokens)},${weeklyCountdown})`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return parts.join(" · ");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
debugDump(result: NonNullable<ProviderUsageResult>, w: (s: string) => void): void {
|
|
128
|
+
if (result.provider !== "zai") return;
|
|
129
|
+
const zai = result as ZaiResult;
|
|
130
|
+
w(` tokensLimitPct: ${zai.tokensLimitPct}%`);
|
|
131
|
+
w(` tokensResetAt: ${new Date(zai.tokensResetAt).toISOString()}`);
|
|
132
|
+
w(` level: ${zai.level}`);
|
|
133
|
+
w(` weeklyTokens: ${zai.weeklyTokens}`);
|
|
134
|
+
w(` weeklyPct: ${zai.weeklyPct}%`);
|
|
135
|
+
w(` isNaturalWeek: ${zai.isNaturalWeek}`);
|
|
136
|
+
w(` weeklyResetAt: ${zai.weeklyResetAt ? new Date(zai.weeklyResetAt).toISOString() : "?"}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Fetch model-usage for a time range. Returns total tokens. */
|
|
140
|
+
private async fetchCycleUsage(
|
|
141
|
+
origin: string,
|
|
142
|
+
headers: Record<string, string>,
|
|
143
|
+
cycleStartMs: number,
|
|
144
|
+
nowMs: number,
|
|
145
|
+
): Promise<number> {
|
|
146
|
+
try {
|
|
147
|
+
const fmtDate = (d: Date) =>
|
|
148
|
+
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}:${String(d.getSeconds()).padStart(2, "0")}`;
|
|
149
|
+
const start = fmtDate(new Date(cycleStartMs));
|
|
150
|
+
const end = fmtDate(new Date(nowMs));
|
|
151
|
+
const url = `${origin}/api/monitor/usage/model-usage?startTime=${encodeURIComponent(start)}&endTime=${encodeURIComponent(end)}`;
|
|
152
|
+
const res = await fetch(url, { headers, signal: AbortSignal.timeout(5000) });
|
|
153
|
+
if (!res.ok) return 0;
|
|
154
|
+
|
|
155
|
+
const usageData = (await res.json()) as {
|
|
156
|
+
data?: {
|
|
157
|
+
totalUsage?: { totalTokensUsage?: number };
|
|
158
|
+
modelSummaryList?: Array<{ modelName: string; totalTokens: number }>;
|
|
159
|
+
} | Array<{ totalTokens?: number }>;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const data = usageData.data;
|
|
163
|
+
if (!data) return 0;
|
|
164
|
+
if (Array.isArray(data)) {
|
|
165
|
+
return data.reduce((sum, e) => sum + (e.totalTokens ?? 0), 0);
|
|
166
|
+
}
|
|
167
|
+
if (data.totalUsage?.totalTokensUsage != null) {
|
|
168
|
+
return data.totalUsage.totalTokensUsage;
|
|
169
|
+
}
|
|
170
|
+
if (Array.isArray(data.modelSummaryList)) {
|
|
171
|
+
return data.modelSummaryList.reduce((sum, e) => sum + (e.totalTokens ?? 0), 0);
|
|
172
|
+
}
|
|
173
|
+
return 0;
|
|
174
|
+
} catch {
|
|
175
|
+
return 0;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { QuotaCalculator, QuotaInfo, WeeklyQuota } from "./types.ts";
|
|
3
|
+
import { scanWeeklyTokens } from "../session-scanner.ts";
|
|
4
|
+
|
|
5
|
+
export class DeepSeekQuotaCalculator implements QuotaCalculator {
|
|
6
|
+
async calculate(
|
|
7
|
+
_modelRegistry: ExtensionContext["modelRegistry"],
|
|
8
|
+
_model: ExtensionContext["model"],
|
|
9
|
+
): Promise<QuotaInfo> {
|
|
10
|
+
const weeklyTokens = scanWeeklyTokens("deepseek");
|
|
11
|
+
|
|
12
|
+
// Next Monday 00:00 UTC
|
|
13
|
+
const now = new Date();
|
|
14
|
+
const dayOfWeek = now.getUTCDay();
|
|
15
|
+
const daysUntilMonday = dayOfWeek === 0 ? 1 : 8 - dayOfWeek;
|
|
16
|
+
const nextMonday = new Date(
|
|
17
|
+
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + daysUntilMonday, 0, 0, 0, 0),
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
const weekly: WeeklyQuota = {
|
|
21
|
+
usedTokens: weeklyTokens,
|
|
22
|
+
resetAt: nextMonday.getTime(),
|
|
23
|
+
crossesNaturalWeek: false,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
rolling: null,
|
|
28
|
+
weekly,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
}
|
package/quota/types.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
/** Rolling quota window (e.g. ZAI 5-hour token quota) */
|
|
4
|
+
export interface RollingQuota {
|
|
5
|
+
/** Usage percentage 0–100 */
|
|
6
|
+
usedPct: number;
|
|
7
|
+
/** Epoch ms when the window resets */
|
|
8
|
+
resetAt: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Weekly subscription quota */
|
|
12
|
+
export interface WeeklyQuota {
|
|
13
|
+
/** Total tokens used in the subscription week */
|
|
14
|
+
usedTokens: number;
|
|
15
|
+
/** Epoch ms when the subscription week resets */
|
|
16
|
+
resetAt: number;
|
|
17
|
+
/** Whether resetAt crosses a natural week boundary */
|
|
18
|
+
crossesNaturalWeek: boolean;
|
|
19
|
+
/** Usage percentage from unit:6 TOKENS_LIMIT (0 if natural week) */
|
|
20
|
+
weeklyPct?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Provider-specific quota. Fields are null when the provider doesn't support them. */
|
|
24
|
+
export interface QuotaInfo {
|
|
25
|
+
rolling: RollingQuota | null;
|
|
26
|
+
weekly: WeeklyQuota | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface QuotaCalculator {
|
|
30
|
+
calculate(
|
|
31
|
+
modelRegistry: ExtensionContext["modelRegistry"],
|
|
32
|
+
model: ExtensionContext["model"],
|
|
33
|
+
): Promise<QuotaInfo>;
|
|
34
|
+
}
|
package/quota/zai.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { QuotaCalculator, QuotaInfo, WeeklyQuota } from "./types.ts";
|
|
3
|
+
import { scanWeeklyTokens } from "../session-scanner.ts";
|
|
4
|
+
|
|
5
|
+
export class ZaiQuotaCalculator implements QuotaCalculator {
|
|
6
|
+
async calculate(
|
|
7
|
+
modelRegistry: ExtensionContext["modelRegistry"],
|
|
8
|
+
model: ExtensionContext["model"],
|
|
9
|
+
): Promise<QuotaInfo> {
|
|
10
|
+
if (!model || (model.provider !== "zai" && model.provider !== "zai-coding-cn")) {
|
|
11
|
+
return { rolling: null, weekly: null };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const apiKey = await modelRegistry.getApiKeyForProvider(model.provider);
|
|
15
|
+
if (!apiKey) return { rolling: null, weekly: null };
|
|
16
|
+
|
|
17
|
+
const defaultOrigin = model.provider === "zai-coding-cn"
|
|
18
|
+
? "https://open.bigmodel.cn"
|
|
19
|
+
: "https://api.z.ai";
|
|
20
|
+
let origin = defaultOrigin;
|
|
21
|
+
try { origin = new URL(model.baseUrl ?? defaultOrigin).origin; } catch { /* use default */ }
|
|
22
|
+
|
|
23
|
+
const headers: Record<string, string> = {
|
|
24
|
+
Authorization: apiKey,
|
|
25
|
+
"Accept-Language": "en-US,en",
|
|
26
|
+
"Content-Type": "application/json",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
let rolling: QuotaInfo["rolling"] = null;
|
|
30
|
+
let weekly: QuotaInfo["weekly"] = null;
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const quotaRes = await fetch(`${origin}/api/monitor/usage/quota/limit`, {
|
|
34
|
+
headers,
|
|
35
|
+
signal: AbortSignal.timeout(5000),
|
|
36
|
+
});
|
|
37
|
+
if (quotaRes.ok) {
|
|
38
|
+
const quotaData = (await quotaRes.json()) as {
|
|
39
|
+
data?: {
|
|
40
|
+
limits?: Array<{
|
|
41
|
+
type: string;
|
|
42
|
+
unit?: number;
|
|
43
|
+
percentage?: number;
|
|
44
|
+
nextResetTime?: number;
|
|
45
|
+
}>;
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const limits = quotaData.data?.limits ?? [];
|
|
50
|
+
const fiveHourLimit = limits.find((l) => l.type === "TOKENS_LIMIT" && l.unit === 3);
|
|
51
|
+
const weeklyLimit = limits.find((l) => l.type === "TOKENS_LIMIT" && l.unit === 6);
|
|
52
|
+
|
|
53
|
+
if (fiveHourLimit) {
|
|
54
|
+
rolling = {
|
|
55
|
+
usedPct: fiveHourLimit.percentage ?? 0,
|
|
56
|
+
resetAt: fiveHourLimit.nextResetTime ?? 0,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (weeklyLimit && weeklyLimit.nextResetTime && weeklyLimit.nextResetTime > 0) {
|
|
61
|
+
// unit:6 — weekly quota from API
|
|
62
|
+
weekly = {
|
|
63
|
+
usedTokens: 0, // actual token count fetched separately by provider
|
|
64
|
+
resetAt: weeklyLimit.nextResetTime ?? 0,
|
|
65
|
+
crossesNaturalWeek: true,
|
|
66
|
+
weeklyPct: weeklyLimit.percentage ?? 0,
|
|
67
|
+
};
|
|
68
|
+
} else {
|
|
69
|
+
// No unit:6 — natural week fallback
|
|
70
|
+
const weeklyTokens = scanWeeklyTokens(model.provider);
|
|
71
|
+
const now = new Date();
|
|
72
|
+
const dayOfWeek = now.getUTCDay();
|
|
73
|
+
const daysUntilMonday = dayOfWeek === 0 ? 1 : 8 - dayOfWeek;
|
|
74
|
+
const nextMonday = new Date(
|
|
75
|
+
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + daysUntilMonday, 0, 0, 0, 0),
|
|
76
|
+
);
|
|
77
|
+
weekly = {
|
|
78
|
+
usedTokens: weeklyTokens,
|
|
79
|
+
resetAt: nextMonday.getTime(),
|
|
80
|
+
crossesNaturalWeek: false,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
// quota unavailable
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { rolling, weekly };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
export function scanWeeklyTokens(providerName: string): number {
|
|
6
|
+
const sessionsDir = join(getAgentDir(), "sessions");
|
|
7
|
+
const now = new Date();
|
|
8
|
+
// Natural week: Monday 00:00 UTC
|
|
9
|
+
const dayOfWeek = now.getUTCDay();
|
|
10
|
+
const mondayOffset = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
|
|
11
|
+
const weekStart = new Date(
|
|
12
|
+
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - mondayOffset, 0, 0, 0, 0),
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
let total = 0;
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const dirs = readdirSync(sessionsDir, { withFileTypes: true });
|
|
19
|
+
for (const dir of dirs) {
|
|
20
|
+
if (!dir.isDirectory()) continue;
|
|
21
|
+
const dirPath = join(sessionsDir, dir.name);
|
|
22
|
+
let files: string[];
|
|
23
|
+
try {
|
|
24
|
+
files = readdirSync(dirPath);
|
|
25
|
+
} catch {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
for (const fname of files) {
|
|
29
|
+
if (!fname.endsWith(".jsonl")) continue;
|
|
30
|
+
try {
|
|
31
|
+
const fileDate = new Date(fname.slice(0, 10) + "T00:00:00Z");
|
|
32
|
+
if (fileDate < weekStart) continue;
|
|
33
|
+
} catch {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const content = readFileSync(join(dirPath, fname), "utf-8");
|
|
39
|
+
for (const line of content.split("\n")) {
|
|
40
|
+
if (!line.trim()) continue;
|
|
41
|
+
try {
|
|
42
|
+
const d = JSON.parse(line);
|
|
43
|
+
if (
|
|
44
|
+
d.type === "message" &&
|
|
45
|
+
d.message?.role === "assistant" &&
|
|
46
|
+
d.message?.provider === providerName
|
|
47
|
+
) {
|
|
48
|
+
total += d.message.usage?.totalTokens ?? 0;
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
// skip malformed lines
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
// skip unreadable files
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
// sessions dir doesn't exist or is unreadable
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return total;
|
|
64
|
+
}
|
package/token-usage.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { TokenUsage, TokenUsageCalculator } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
export class SessionTokenUsageCalculator implements TokenUsageCalculator {
|
|
6
|
+
private getCurrencyOverride: () => "\u00a5" | "$" | undefined;
|
|
7
|
+
|
|
8
|
+
constructor(getCurrencyOverride: () => "\u00a5" | "$" | undefined) {
|
|
9
|
+
this.getCurrencyOverride = getCurrencyOverride;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
compute(ctx: ExtensionContext): TokenUsage & { cost: number; currency: "\u00a5" | "$" } {
|
|
13
|
+
let input = 0;
|
|
14
|
+
let output = 0;
|
|
15
|
+
let cacheRead = 0;
|
|
16
|
+
let cacheWrite = 0;
|
|
17
|
+
let total = 0;
|
|
18
|
+
let cost = 0;
|
|
19
|
+
|
|
20
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
21
|
+
if (entry.type === "message" && entry.message.role === "assistant") {
|
|
22
|
+
const m = entry.message as AssistantMessage;
|
|
23
|
+
input += m.usage.input;
|
|
24
|
+
output += m.usage.output;
|
|
25
|
+
cacheRead += m.usage.cacheRead;
|
|
26
|
+
cacheWrite += m.usage.cacheWrite;
|
|
27
|
+
total += m.usage.totalTokens;
|
|
28
|
+
cost += m.usage.cost.total;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const hitRate = input + cacheRead > 0 ? cacheRead / (input + cacheRead) : 0;
|
|
33
|
+
|
|
34
|
+
let currency: "\u00a5" | "$" = "$";
|
|
35
|
+
const override = this.getCurrencyOverride();
|
|
36
|
+
if (override) {
|
|
37
|
+
currency = override;
|
|
38
|
+
} else {
|
|
39
|
+
const p = ctx.model?.provider ?? "";
|
|
40
|
+
if (p.toLowerCase().includes("deepseek")) currency = "\u00a5";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { input, output, cacheRead, cacheWrite, total, hitRate, cost, currency };
|
|
44
|
+
}
|
|
45
|
+
}
|
package/types.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Token usage
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
export interface TokenUsage {
|
|
8
|
+
input: number;
|
|
9
|
+
output: number;
|
|
10
|
+
cacheRead: number;
|
|
11
|
+
cacheWrite: number;
|
|
12
|
+
total: number;
|
|
13
|
+
/** cache hit ratio: cacheRead / (input + cacheRead), 0 when denominator is 0 */
|
|
14
|
+
hitRate: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface TokenUsageCalculator {
|
|
18
|
+
compute(ctx: ExtensionContext): TokenUsage & { cost: number; currency: "¥" | "$" };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Provider result types
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
export type DeepSeekResult = {
|
|
26
|
+
provider: "deepseek";
|
|
27
|
+
totalBalance: string;
|
|
28
|
+
currency: string;
|
|
29
|
+
weeklyTokens: number;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type ZaiResult = {
|
|
33
|
+
provider: "zai";
|
|
34
|
+
tokensLimitPct: number;
|
|
35
|
+
tokensResetAt: number;
|
|
36
|
+
level: string;
|
|
37
|
+
weeklyTokens: number;
|
|
38
|
+
weeklyResetAt: number;
|
|
39
|
+
weeklyPct: number;
|
|
40
|
+
isNaturalWeek: boolean;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type ProviderUsageResult = DeepSeekResult | ZaiResult | null;
|