pi-harness-runtime 0.2.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 +41 -0
- package/LICENSE +21 -0
- package/README.md +207 -0
- package/cli.ts +111 -0
- package/index.ts +291 -0
- package/mirror.ts +87 -0
- package/package.json +65 -0
- package/renderer.ts +170 -0
- package/skills/harness-runtime/SKILL.md +95 -0
- package/sync-form.ts +104 -0
- package/tracker.ts +72 -0
- package/windows.ts +121 -0
package/mirror.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MirrorStore — manual sync of provider-side quota from console.
|
|
3
|
+
*
|
|
4
|
+
* User periodically glances at https://platform.minimax.io/console/usage
|
|
5
|
+
* and runs `/usage sync` to enter:
|
|
6
|
+
* - 5h used %
|
|
7
|
+
* - 5h resets in (h, m)
|
|
8
|
+
* - weekly used %
|
|
9
|
+
* - weekly resets in (d, h)
|
|
10
|
+
*
|
|
11
|
+
* This is the "ground truth" since most providers (MiniMax, Anthropic, OpenAI)
|
|
12
|
+
* don't expose rate limit headers publicly. Local tracking counts OUR usage
|
|
13
|
+
* only; the mirror counts TOTAL quota usage across all clients.
|
|
14
|
+
*
|
|
15
|
+
* File: ~/.pi/usage-status/mirror.json
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
getMirrorPath,
|
|
20
|
+
readJson,
|
|
21
|
+
writeJson,
|
|
22
|
+
} from "./cli.ts";
|
|
23
|
+
|
|
24
|
+
export interface MirrorRecord {
|
|
25
|
+
synced_at: string; // ISO 8601 UTC
|
|
26
|
+
provider: string; // e.g. "minimax"
|
|
27
|
+
model?: string; // optional, e.g. "minimax/MiniMax-M3"
|
|
28
|
+
h5_used_pct?: number; // 0-100
|
|
29
|
+
h5_resets_at?: string; // ISO 8601 UTC — provider-reported
|
|
30
|
+
weekly_used_pct?: number; // 0-100
|
|
31
|
+
weekly_resets_at?: string; // ISO 8601 UTC — provider-reported
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const STALE_WARN_MS = 30 * 60 * 1000; // 30 min → orange
|
|
35
|
+
const STALE_ERROR_MS = 2 * 60 * 60 * 1000; // 2 h → red
|
|
36
|
+
|
|
37
|
+
export class MirrorStore {
|
|
38
|
+
private path: string;
|
|
39
|
+
|
|
40
|
+
constructor(path: string = getMirrorPath()) {
|
|
41
|
+
this.path = path;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Read the mirror record. Returns null if missing/corrupted. */
|
|
45
|
+
read(): MirrorRecord | null {
|
|
46
|
+
const raw = readJson(this.path);
|
|
47
|
+
if (!raw || typeof raw !== "object") return null;
|
|
48
|
+
return raw as MirrorRecord;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Write a new mirror record (overwrites). */
|
|
52
|
+
write(record: MirrorRecord): void {
|
|
53
|
+
writeJson(this.path, record);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Returns "fresh" | "stale" | "expired" based on age. */
|
|
57
|
+
freshness(record: MirrorRecord | null, nowMs: number): "fresh" | "stale" | "expired" | "missing" {
|
|
58
|
+
if (!record || !record.synced_at) return "missing";
|
|
59
|
+
const syncedMs = Date.parse(record.synced_at);
|
|
60
|
+
if (isNaN(syncedMs)) return "missing";
|
|
61
|
+
const ageMs = nowMs - syncedMs;
|
|
62
|
+
if (ageMs < STALE_WARN_MS) return "fresh";
|
|
63
|
+
if (ageMs < STALE_ERROR_MS) return "stale";
|
|
64
|
+
return "expired";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** True if data is too stale to trust (> 2 hours old). */
|
|
68
|
+
isExpired(record: MirrorRecord | null, nowMs: number): boolean {
|
|
69
|
+
return this.freshness(record, nowMs) === "expired";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Convenience: human-readable age like "5 min ago" or "1 d 2 h ago". */
|
|
73
|
+
ageString(record: MirrorRecord | null, nowMs: number): string {
|
|
74
|
+
if (!record || !record.synced_at) return "never";
|
|
75
|
+
const syncedMs = Date.parse(record.synced_at);
|
|
76
|
+
if (isNaN(syncedMs)) return "unknown";
|
|
77
|
+
const delta = nowMs - syncedMs;
|
|
78
|
+
const sec = Math.floor(delta / 1000);
|
|
79
|
+
if (sec < 60) return `${sec}s ago`;
|
|
80
|
+
const min = Math.floor(sec / 60);
|
|
81
|
+
if (min < 60) return `${min} min ago`;
|
|
82
|
+
const hr = Math.floor(min / 60);
|
|
83
|
+
if (hr < 24) return `${hr} h ago`;
|
|
84
|
+
const day = Math.floor(hr / 24);
|
|
85
|
+
return `${day} d ago`;
|
|
86
|
+
}
|
|
87
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-harness-runtime",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Codex-style /usage status for pi: local token tracking + provider mirror",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "node --test",
|
|
8
|
+
"release": "standard-version",
|
|
9
|
+
"release:patch": "standard-version --release-as patch",
|
|
10
|
+
"release:minor": "standard-version --release-as minor",
|
|
11
|
+
"release:major": "standard-version --release-as major",
|
|
12
|
+
"release:dry-run": "standard-version --dry-run"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"index.ts",
|
|
16
|
+
"tracker.ts",
|
|
17
|
+
"mirror.ts",
|
|
18
|
+
"windows.ts",
|
|
19
|
+
"renderer.ts",
|
|
20
|
+
"sync-form.ts",
|
|
21
|
+
"cli.ts",
|
|
22
|
+
"skills",
|
|
23
|
+
"package.json",
|
|
24
|
+
"README.md",
|
|
25
|
+
"CHANGELOG.md",
|
|
26
|
+
"LICENSE"
|
|
27
|
+
],
|
|
28
|
+
"keywords": [
|
|
29
|
+
"pi-package",
|
|
30
|
+
"pi",
|
|
31
|
+
"pi-coding-agent",
|
|
32
|
+
"extension",
|
|
33
|
+
"usage",
|
|
34
|
+
"tokens",
|
|
35
|
+
"cost",
|
|
36
|
+
"codex"
|
|
37
|
+
],
|
|
38
|
+
"author": "MooCoding",
|
|
39
|
+
"license": "MIT",
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "git+https://github.com/ManotLuijiu/pi-harness-runtime.git"
|
|
43
|
+
},
|
|
44
|
+
"bugs": {
|
|
45
|
+
"url": "https://github.com/ManotLuijiu/pi-harness-runtime/issues"
|
|
46
|
+
},
|
|
47
|
+
"homepage": "https://github.com/ManotLuijiu/pi-harness-runtime#readme",
|
|
48
|
+
"pi": {
|
|
49
|
+
"extensions": [
|
|
50
|
+
"./index.ts"
|
|
51
|
+
],
|
|
52
|
+
"skills": [
|
|
53
|
+
"./skills"
|
|
54
|
+
]
|
|
55
|
+
},
|
|
56
|
+
"peerDependencies": {
|
|
57
|
+
"@earendil-works/pi-ai": "*",
|
|
58
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
59
|
+
"@earendil-works/pi-tui": "*",
|
|
60
|
+
"typebox": "*"
|
|
61
|
+
},
|
|
62
|
+
"devDependencies": {
|
|
63
|
+
"standard-version": "^9.5.0"
|
|
64
|
+
}
|
|
65
|
+
}
|
package/renderer.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StatusRenderer — Codex-style output formatting.
|
|
3
|
+
*
|
|
4
|
+
* Produces the same visual style as Codex's `/status`:
|
|
5
|
+
* 5h limit: [████████░░░░░░░░░░░░] 18% left (resets in 4h 56m)
|
|
6
|
+
* Weekly limit: [████████████████░░░░] 81% left (resets in 2d 13h)
|
|
7
|
+
*
|
|
8
|
+
* We render with plain text (no TUI dependency) so it's testable with
|
|
9
|
+
* node --test and works in `ctx.ui.notify()`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { formatDuration, formatRelative, formatTokens, formatUsd } from "./cli.ts";
|
|
13
|
+
import type { AggregatedWindows } from "./windows.ts";
|
|
14
|
+
import { FIVE_HOURS_MS, SEVEN_DAYS_MS, computeLocalResetTime } from "./windows.ts";
|
|
15
|
+
import type { MirrorRecord } from "./mirror.ts";
|
|
16
|
+
import type { MirrorStore } from "./mirror.ts";
|
|
17
|
+
|
|
18
|
+
const BAR_WIDTH = 20;
|
|
19
|
+
const FILLED = "█";
|
|
20
|
+
const EMPTY = "░";
|
|
21
|
+
|
|
22
|
+
export interface RenderInput {
|
|
23
|
+
model: string | null;
|
|
24
|
+
cwd: string;
|
|
25
|
+
local: AggregatedWindows;
|
|
26
|
+
mirror: MirrorRecord | null;
|
|
27
|
+
mirrorStore: MirrorStore; // for freshness check
|
|
28
|
+
nowMs: number;
|
|
29
|
+
// Optional: local usage limit configuration (for "X% of limit used")
|
|
30
|
+
localFiveHLimitTokens?: number;
|
|
31
|
+
localWeeklyLimitTokens?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function renderProgressBar(pct: number, width: number = BAR_WIDTH): string {
|
|
35
|
+
const clamped = Math.max(0, Math.min(100, pct));
|
|
36
|
+
const filledCount = Math.round((clamped / 100) * width);
|
|
37
|
+
const emptyCount = width - filledCount;
|
|
38
|
+
return "[" + FILLED.repeat(filledCount) + EMPTY.repeat(emptyCount) + "]";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Render the "18% left" style label (what's REMAINING, like Codex). */
|
|
42
|
+
function renderLeftLabel(pct: number): string {
|
|
43
|
+
const left = Math.max(0, Math.min(100, 100 - pct));
|
|
44
|
+
return `${left.toFixed(0)}% left`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Render the full status block. */
|
|
48
|
+
export function renderStatus(input: RenderInput): string {
|
|
49
|
+
const lines: string[] = [];
|
|
50
|
+
const divider = "─".repeat(64);
|
|
51
|
+
|
|
52
|
+
// ─── Header ──────────────────────────────────────────────────────────
|
|
53
|
+
lines.push("Codex-style usage status for pi");
|
|
54
|
+
lines.push(divider);
|
|
55
|
+
lines.push(` Model: ${input.model ?? "unknown"}`);
|
|
56
|
+
lines.push(` Directory: ${input.cwd}`);
|
|
57
|
+
lines.push("");
|
|
58
|
+
|
|
59
|
+
// ─── Local tracking ─────────────────────────────────────────────────
|
|
60
|
+
lines.push(" ① LOCAL TRACKED (ground truth — we count this)");
|
|
61
|
+
lines.push(
|
|
62
|
+
` This session: ${formatUsd(input.local.lifetime.cost)} · ${formatTokens(input.local.lifetime.tokens)} tokens · ${input.local.lifetime.requests} requests`,
|
|
63
|
+
);
|
|
64
|
+
lines.push(
|
|
65
|
+
` This 5h: ${formatTokens(input.local.five_h.tokens)} tokens · ${input.local.five_h.requests} requests · ${formatUsd(input.local.five_h.cost)}`,
|
|
66
|
+
);
|
|
67
|
+
lines.push(
|
|
68
|
+
` This week: ${formatTokens(input.local.weekly.tokens)} tokens · ${input.local.weekly.requests} requests · ${formatUsd(input.local.weekly.cost)}`,
|
|
69
|
+
);
|
|
70
|
+
lines.push(
|
|
71
|
+
` Lifetime: ${input.local.lifetime.requests} requests · ${formatUsd(input.local.lifetime.cost)}`,
|
|
72
|
+
);
|
|
73
|
+
lines.push("");
|
|
74
|
+
|
|
75
|
+
// ─── Provider mirror ─────────────────────────────────────────────────
|
|
76
|
+
if (input.mirror) {
|
|
77
|
+
const fresh = input.mirrorStore.freshness(input.mirror, input.nowMs);
|
|
78
|
+
const freshnessLabel = fresh === "fresh" ? "fresh" : fresh === "stale" ? "stale" : fresh === "expired" ? "EXPIRED" : "missing";
|
|
79
|
+
lines.push(` ② PROVIDER MIRROR (you enter from console.minimax.io)`);
|
|
80
|
+
lines.push(` Last sync: ${formatRelative(input.mirror.synced_at, input.nowMs)} [${freshnessLabel}]`);
|
|
81
|
+
lines.push(` Provider: ${input.mirror.provider ?? "unknown"}`);
|
|
82
|
+
|
|
83
|
+
// 5h line
|
|
84
|
+
if (input.mirror.h5_used_pct !== undefined) {
|
|
85
|
+
const pct = input.mirror.h5_used_pct;
|
|
86
|
+
const resetStr = input.mirror.h5_resets_at
|
|
87
|
+
? formatDuration(Date.parse(input.mirror.h5_resets_at) - input.nowMs)
|
|
88
|
+
: "unknown";
|
|
89
|
+
lines.push(
|
|
90
|
+
` 5h limit: ${renderProgressBar(pct)} ${renderLeftLabel(pct)} (resets in ${resetStr})`,
|
|
91
|
+
);
|
|
92
|
+
} else {
|
|
93
|
+
lines.push(` 5h limit: (not yet synced — run /usage sync)`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Weekly line
|
|
97
|
+
if (input.mirror.weekly_used_pct !== undefined) {
|
|
98
|
+
const pct = input.mirror.weekly_used_pct;
|
|
99
|
+
const resetStr = input.mirror.weekly_resets_at
|
|
100
|
+
? formatDuration(Date.parse(input.mirror.weekly_resets_at) - input.nowMs)
|
|
101
|
+
: "unknown";
|
|
102
|
+
lines.push(
|
|
103
|
+
` Weekly limit: ${renderProgressBar(pct)} ${renderLeftLabel(pct)} (resets in ${resetStr})`,
|
|
104
|
+
);
|
|
105
|
+
} else {
|
|
106
|
+
lines.push(` Weekly limit: (not yet synced — run /usage sync)`);
|
|
107
|
+
}
|
|
108
|
+
lines.push("");
|
|
109
|
+
} else {
|
|
110
|
+
lines.push(` ② PROVIDER MIRROR`);
|
|
111
|
+
lines.push(` Not synced yet. Run /usage sync to mirror from console.minimax.io.`);
|
|
112
|
+
lines.push("");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ─── Local reset times (derived) ────────────────────────────────────
|
|
116
|
+
lines.push(" ③ LOCAL RESET TIMES (derived from your data)");
|
|
117
|
+
const local5hReset = computeLocalResetTime(input.local.five_h, FIVE_HOURS_MS);
|
|
118
|
+
const localWeekReset = computeLocalResetTime(input.local.weekly, SEVEN_DAYS_MS);
|
|
119
|
+
if (local5hReset) {
|
|
120
|
+
const remaining = local5hReset - input.nowMs;
|
|
121
|
+
lines.push(
|
|
122
|
+
` Local 5h reset: in ${formatDuration(remaining)} (oldest request falls out of window)`,
|
|
123
|
+
);
|
|
124
|
+
} else {
|
|
125
|
+
lines.push(` Local 5h reset: no requests in last 5 hours`);
|
|
126
|
+
}
|
|
127
|
+
if (localWeekReset) {
|
|
128
|
+
const remaining = localWeekReset - input.nowMs;
|
|
129
|
+
lines.push(
|
|
130
|
+
` Local week reset: in ${formatDuration(remaining)} (oldest request falls out of window)`,
|
|
131
|
+
);
|
|
132
|
+
} else {
|
|
133
|
+
lines.push(` Local week reset: no requests in last 7 days`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ─── Local-vs-mirror divergence ─────────────────────────────────────
|
|
137
|
+
if (input.mirror?.h5_used_pct !== undefined) {
|
|
138
|
+
const localPct = input.localFiveHLimitTokens
|
|
139
|
+
? (input.local.five_h.tokens / input.localFiveHLimitTokens) * 100
|
|
140
|
+
: 0;
|
|
141
|
+
const delta = localPct - input.mirror.h5_used_pct;
|
|
142
|
+
const deltaStr = delta >= 0 ? `+${delta.toFixed(1)}%` : `${delta.toFixed(1)}%`;
|
|
143
|
+
const warning = Math.abs(delta) > 5 ? " ⚠️ divergence > 5%" : "";
|
|
144
|
+
lines.push(` Local-vs-mirror: ${deltaStr}${warning}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ─── Burn rate ──────────────────────────────────────────────────────
|
|
148
|
+
if (input.mirror?.weekly_used_pct !== undefined && input.mirror.weekly_resets_at) {
|
|
149
|
+
const resetMs = Date.parse(input.mirror.weekly_resets_at);
|
|
150
|
+
const elapsedMs = input.nowMs - resetMs;
|
|
151
|
+
const elapsedDays = elapsedMs / (24 * 60 * 60 * 1000);
|
|
152
|
+
if (elapsedDays > 0) {
|
|
153
|
+
const pctPerDay = input.mirror.weekly_used_pct / elapsedDays;
|
|
154
|
+
const remaining = 100 - input.mirror.weekly_used_pct;
|
|
155
|
+
const daysLeft = pctPerDay > 0 ? remaining / pctPerDay : Infinity;
|
|
156
|
+
const daysLeftStr = daysLeft === Infinity ? "∞" : `${daysLeft.toFixed(1)} d`;
|
|
157
|
+
lines.push(
|
|
158
|
+
` Burn rate: ${pctPerDay.toFixed(1)}% / day → 100% in ${daysLeftStr}`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
lines.push(divider);
|
|
164
|
+
lines.push(` Data dir: ${process.env.PI_USAGE_DIR ?? "~/.pi/usage-status"}`);
|
|
165
|
+
lines.push(` Local time: ${new Date(input.nowMs).toISOString()}`);
|
|
166
|
+
lines.push("");
|
|
167
|
+
lines.push(" Run `/usage sync` to update the provider mirror.");
|
|
168
|
+
|
|
169
|
+
return lines.join("\n");
|
|
170
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: harness-runtime
|
|
3
|
+
description: Show Codex-style /usage status for pi — local token tracking + manual provider mirror. Use when the user asks about token usage, API quota, 5h limit, weekly limit, or wants to know how much they've spent.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# pi-harness-runtime
|
|
7
|
+
|
|
8
|
+
Codex-style `/usage` slash command for pi coding agent.
|
|
9
|
+
|
|
10
|
+
## When to use
|
|
11
|
+
|
|
12
|
+
User says:
|
|
13
|
+
|
|
14
|
+
- "show me my usage"
|
|
15
|
+
- "how much have I used?"
|
|
16
|
+
- "what's my 5h limit?"
|
|
17
|
+
- "weekly quota?"
|
|
18
|
+
- "/usage"
|
|
19
|
+
- "/usage sync"
|
|
20
|
+
- "how many tokens today?"
|
|
21
|
+
|
|
22
|
+
## Quick reference
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
/usage # full status (model, local tracking, provider mirror)
|
|
26
|
+
/usage sync # open form to mirror provider-side quota
|
|
27
|
+
/usage today # focused: this 5h + today (UTC)
|
|
28
|
+
/usage week # focused: this week + lifetime
|
|
29
|
+
/usage reset # clear provider mirror
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Data sources (3-source model)
|
|
33
|
+
|
|
34
|
+
1. **Local tracked** — every assistant message is logged to `~/.pi/usage-status/usage.jsonl`
|
|
35
|
+
- Auto-tracked via `message_end` event
|
|
36
|
+
- Contains: timestamp, model, input/output/cache tokens, cost
|
|
37
|
+
- Real-time, exact, but only counts THIS pi session
|
|
38
|
+
|
|
39
|
+
2. **Provider mirror** — manually entered from `https://platform.minimax.io/console/usage`
|
|
40
|
+
- Stored at `~/.pi/usage-status/mirror.json`
|
|
41
|
+
- Synced via `/usage sync` form
|
|
42
|
+
- Ground truth for TOTAL quota (across all clients)
|
|
43
|
+
|
|
44
|
+
3. **Derived** — burn rate, reset times, divergence
|
|
45
|
+
- Local reset time = oldest request in window + window duration
|
|
46
|
+
- Burn rate = mirror weekly % / elapsed days
|
|
47
|
+
- Divergence warning if local tracking differs from mirror by >5%
|
|
48
|
+
|
|
49
|
+
## Files written
|
|
50
|
+
|
|
51
|
+
- `~/.pi/usage-status/usage.jsonl` — append-only usage log
|
|
52
|
+
- `~/.pi/usage-status/mirror.json` — manual provider mirror
|
|
53
|
+
|
|
54
|
+
Override location with `PI_USAGE_DIR` env var.
|
|
55
|
+
|
|
56
|
+
## Sample output
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
Codex-style usage status for pi
|
|
60
|
+
────────────────────────────────────────────────────────────────
|
|
61
|
+
Model: minimax/MiniMax-M3
|
|
62
|
+
Directory: ~/frappe-bench/apps/thai_business_suite
|
|
63
|
+
|
|
64
|
+
① LOCAL TRACKED (ground truth — we count this)
|
|
65
|
+
This session: $0.17 · 142k tokens · 17 requests
|
|
66
|
+
This 5h: 384k tokens · 23 requests · $0.04
|
|
67
|
+
This week: 1.2M tokens · 67 requests · $0.13
|
|
68
|
+
Lifetime: 4592 requests · $81.61
|
|
69
|
+
|
|
70
|
+
② PROVIDER MIRROR (you enter from console.minimax.io)
|
|
71
|
+
Last sync: 2 min ago [fresh]
|
|
72
|
+
Provider: minimax
|
|
73
|
+
5h limit: [████████░░░░░░░░░░░░] 18% left (resets in 4h 54m)
|
|
74
|
+
Weekly limit: [████████████████░░░░] 81% left (resets in 2d 13h)
|
|
75
|
+
|
|
76
|
+
③ LOCAL RESET TIMES (derived from your data)
|
|
77
|
+
Local 5h reset: in 3h 12m (oldest request falls out of window)
|
|
78
|
+
Local week reset: in 5d 7h (oldest request falls out of window)
|
|
79
|
+
Local-vs-mirror: -12.4% ⚠️ divergence > 5%
|
|
80
|
+
Burn rate: 11.4% / day → 100% in 2.5 d
|
|
81
|
+
────────────────────────────────────────────────────────────────
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Safety properties
|
|
85
|
+
|
|
86
|
+
- **No auto-tracking of other clients** — local data is just this pi session
|
|
87
|
+
- **No scraping** — provider mirror is manual (5-second task)
|
|
88
|
+
- **No fabrication** — divergence warning if local and mirror disagree by >5%
|
|
89
|
+
- **Idempotent** — running `/usage` repeatedly has no side effects
|
|
90
|
+
- **Privacy-respecting** — all data stays on local disk
|
|
91
|
+
|
|
92
|
+
## Related
|
|
93
|
+
|
|
94
|
+
- `context-mode` provides overall session cost via `ctx_stats`
|
|
95
|
+
- pi's built-in footer shows model + git branch
|
package/sync-form.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync form — opens an interactive form for the user to mirror provider-side
|
|
3
|
+
* quota from console.minimax.io (or any provider dashboard).
|
|
4
|
+
*
|
|
5
|
+
* Uses ctx.ui.custom() to build a small TUI form with 4 fields:
|
|
6
|
+
* - 5h used % (number 0-100)
|
|
7
|
+
* - 5h resets in (h, m)
|
|
8
|
+
* - weekly % (number 0-100)
|
|
9
|
+
* - weekly resets in (d, h)
|
|
10
|
+
*
|
|
11
|
+
* On submit, writes a MirrorRecord to disk.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { MirrorRecord } from "./mirror.ts";
|
|
15
|
+
import type { MirrorStore } from "./mirror.ts";
|
|
16
|
+
|
|
17
|
+
export interface SyncFormValues {
|
|
18
|
+
h5_used_pct: number;
|
|
19
|
+
h5_resets_h: number;
|
|
20
|
+
h5_resets_m: number;
|
|
21
|
+
weekly_used_pct: number;
|
|
22
|
+
weekly_resets_d: number;
|
|
23
|
+
weekly_resets_h: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Compute ISO 8601 reset time from "in Hh Mm" relative to now. */
|
|
27
|
+
export function computeResetIso(nowMs: number, h: number, m: number): string {
|
|
28
|
+
return new Date(nowMs + h * 3600_000 + m * 60_000).toISOString();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Build a MirrorRecord from form values. */
|
|
32
|
+
export function buildMirrorRecord(
|
|
33
|
+
values: SyncFormValues,
|
|
34
|
+
provider: string,
|
|
35
|
+
nowMs: number,
|
|
36
|
+
): MirrorRecord {
|
|
37
|
+
return {
|
|
38
|
+
synced_at: new Date(nowMs).toISOString(),
|
|
39
|
+
provider,
|
|
40
|
+
h5_used_pct: values.h5_used_pct,
|
|
41
|
+
h5_resets_at: computeResetIso(nowMs, values.h5_resets_h, values.h5_resets_m),
|
|
42
|
+
weekly_used_pct: values.weekly_used_pct,
|
|
43
|
+
weekly_resets_at: computeResetIso(nowMs, values.weekly_resets_d * 24 + values.weekly_resets_h, 0),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Open the sync form. Implemented as a thin wrapper around ctx.ui.custom()
|
|
49
|
+
* — the actual TUI component lives in sync-form-ui.ts (or inline here).
|
|
50
|
+
*
|
|
51
|
+
* For simplicity (and testability), this file exports the pure form-data
|
|
52
|
+
* conversion. The actual TUI rendering is handled in index.ts which has
|
|
53
|
+
* access to ExtensionContext.
|
|
54
|
+
*/
|
|
55
|
+
export async function handleSyncSubmit(
|
|
56
|
+
values: SyncFormValues,
|
|
57
|
+
mirrorStore: MirrorStore,
|
|
58
|
+
provider: string = "minimax",
|
|
59
|
+
): Promise<MirrorRecord> {
|
|
60
|
+
const now = Date.now();
|
|
61
|
+
const record = buildMirrorRecord(values, provider, now);
|
|
62
|
+
mirrorStore.write(record);
|
|
63
|
+
return record;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Parse a mirror record from raw form input strings (validates).
|
|
68
|
+
* Returns null if invalid.
|
|
69
|
+
*/
|
|
70
|
+
export function parseSyncValues(input: {
|
|
71
|
+
h5_used_pct: string;
|
|
72
|
+
h5_resets_h: string;
|
|
73
|
+
h5_resets_m: string;
|
|
74
|
+
weekly_used_pct: string;
|
|
75
|
+
weekly_resets_d: string;
|
|
76
|
+
weekly_resets_h: string;
|
|
77
|
+
}): SyncFormValues | null {
|
|
78
|
+
const h5_used = Number(input.h5_used_pct);
|
|
79
|
+
const h5_h = Number(input.h5_resets_h);
|
|
80
|
+
const h5_m = Number(input.h5_resets_m);
|
|
81
|
+
const wk_used = Number(input.weekly_used_pct);
|
|
82
|
+
const wk_d = Number(input.weekly_resets_d);
|
|
83
|
+
const wk_h = Number(input.weekly_resets_h);
|
|
84
|
+
|
|
85
|
+
if (
|
|
86
|
+
!Number.isFinite(h5_used) || h5_used < 0 || h5_used > 100 ||
|
|
87
|
+
!Number.isFinite(h5_h) || h5_h < 0 || h5_h > 24 ||
|
|
88
|
+
!Number.isFinite(h5_m) || h5_m < 0 || h5_m > 59 ||
|
|
89
|
+
!Number.isFinite(wk_used) || wk_used < 0 || wk_used > 100 ||
|
|
90
|
+
!Number.isFinite(wk_d) || wk_d < 0 || wk_d > 7 ||
|
|
91
|
+
!Number.isFinite(wk_h) || wk_h < 0 || wk_h > 23
|
|
92
|
+
) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
h5_used_pct: h5_used,
|
|
98
|
+
h5_resets_h: h5_h,
|
|
99
|
+
h5_resets_m: h5_m,
|
|
100
|
+
weekly_used_pct: wk_used,
|
|
101
|
+
weekly_resets_d: wk_d,
|
|
102
|
+
weekly_resets_h: wk_h,
|
|
103
|
+
};
|
|
104
|
+
}
|
package/tracker.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UsageTracker — append-only JSONL log of every assistant message.
|
|
3
|
+
*
|
|
4
|
+
* Stores one record per assistant message with: timestamp, model id,
|
|
5
|
+
* input/output tokens, cache read/write tokens, total cost USD.
|
|
6
|
+
*
|
|
7
|
+
* File: ~/.pi/usage-status/usage.jsonl (one JSON object per line)
|
|
8
|
+
*
|
|
9
|
+
* No locking — single-process pi uses single-writer. Multi-process safety
|
|
10
|
+
* is not a goal; SQLite would be needed for that.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
appendJsonl,
|
|
15
|
+
ensureUsageDir,
|
|
16
|
+
getUsageLogPath,
|
|
17
|
+
readJsonl,
|
|
18
|
+
} from "./cli.ts";
|
|
19
|
+
import { unlinkSync } from "node:fs";
|
|
20
|
+
|
|
21
|
+
export interface UsageRecord {
|
|
22
|
+
ts: number; // unix ms
|
|
23
|
+
model: string; // model id, e.g. "minimax/MiniMax-M3"
|
|
24
|
+
input: number;
|
|
25
|
+
output: number;
|
|
26
|
+
cache_read: number;
|
|
27
|
+
cache_write: number;
|
|
28
|
+
cost: number; // USD total
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class UsageTracker {
|
|
32
|
+
private path: string;
|
|
33
|
+
|
|
34
|
+
constructor(path: string = getUsageLogPath()) {
|
|
35
|
+
this.path = path;
|
|
36
|
+
ensureUsageDir();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Append one usage record. */
|
|
40
|
+
append(record: UsageRecord): void {
|
|
41
|
+
appendJsonl(this.path, record);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Read all records (newest last). Returns [] if file missing. */
|
|
45
|
+
all(): UsageRecord[] {
|
|
46
|
+
return readJsonl<UsageRecord>(this.path);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Filter records newer than `sinceMs` (inclusive). */
|
|
50
|
+
since(sinceMs: number): UsageRecord[] {
|
|
51
|
+
return this.all().filter((r) => r.ts >= sinceMs);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Filter records within [fromMs, toMs). */
|
|
55
|
+
between(fromMs: number, toMs: number): UsageRecord[] {
|
|
56
|
+
return this.all().filter((r) => r.ts >= fromMs && r.ts < toMs);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Clear all records (testing only). */
|
|
60
|
+
clear(): void {
|
|
61
|
+
try {
|
|
62
|
+
unlinkSync(this.path);
|
|
63
|
+
} catch {
|
|
64
|
+
// ignore
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Total record count. */
|
|
69
|
+
count(): number {
|
|
70
|
+
return this.all().length;
|
|
71
|
+
}
|
|
72
|
+
}
|