pi-harness-runtime 0.10.13 → 0.10.14

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/mirror.js ADDED
@@ -0,0 +1,205 @@
1
+ /**
2
+ * MirrorStore — cached per-provider quota snapshot.
3
+ *
4
+ * Auto refresh writes per-provider data to ~/.pi/usage-status/mirror.json so
5
+ * the footer status and `/usage` can render the latest provider-side
6
+ * usage. Local tracking counts OUR usage; the mirror counts what's
7
+ * currently knowable from the provider (continuous for MiniMax via
8
+ * scrape; one-shot TUI signal for OpenAI / GLM / etc.).
9
+ *
10
+ * Shape (per-provider map):
11
+ *
12
+ * ```jsonc
13
+ * {
14
+ * "minimax": {
15
+ * "synced_at": "2026-07-23T06:20:04Z",
16
+ * "source": "scrape" | "tui-signal",
17
+ * "h5_used_pct": 15,
18
+ * "h5_resets_at": "3 hr 39 min",
19
+ * "weekly_used_pct": 21,
20
+ * "weekly_resets_at": "3 days 17 hr 39 min"
21
+ * },
22
+ * "openai": { ... }
23
+ * }
24
+ * ```
25
+ *
26
+ * Back-compat: legacy single-row files are read and upgraded in place
27
+ * on the first `readAll()` call after upgrade. Old rows that match a
28
+ * known provider (have a `provider` field) are placed under that key.
29
+ *
30
+ * Models live here: `~/.pi/usage-status/mirror.json`.
31
+ */
32
+ import { getMirrorPath, readJson, writeJson } from "./cli.ts";
33
+ /**
34
+ * Canonical lowercase provider ids — mirrors the KNOWN_AI_PROVIDERS from
35
+ * packages/types/src/ai-providers.ts. Duplicated here to avoid import path
36
+ * resolution issues in the root workspace.
37
+ */
38
+ const KNOWN_PROVIDER_IDS = [
39
+ "minimax",
40
+ "openai",
41
+ "anthropic",
42
+ "glm",
43
+ "openrouter",
44
+ "openai-codex",
45
+ "deepseek",
46
+ "gemini",
47
+ "kimi",
48
+ ];
49
+ function isKnownProvider(id) {
50
+ return KNOWN_PROVIDER_IDS.includes(id);
51
+ }
52
+ const STALE_WARN_MS = 30 * 60 * 1000; // 30 min → orange
53
+ const STALE_ERROR_MS = 2 * 60 * 60 * 1000; // 2 h → red
54
+ /**
55
+ * Detect whether a raw read looks like a legacy single-row shape
56
+ * (i.e. not yet the per-provider map). Used to gate the upgrade path.
57
+ */
58
+ function isLegacyShape(raw) {
59
+ if (!raw || typeof raw !== "object")
60
+ return false;
61
+ const obj = raw;
62
+ if ("provider" in obj && typeof obj.provider === "string") {
63
+ // Legacy single-row shape: has flat provider string, no map-of-records structure.
64
+ return true;
65
+ }
66
+ return false;
67
+ }
68
+ /** Convert a legacy single-row record to per-provider shape. */
69
+ function upgradeLegacyRecord(legacy) {
70
+ const provider = legacy.provider ?? "minimax";
71
+ const known = isKnownProvider(provider) ? provider : "minimax";
72
+ const rec = {
73
+ synced_at: legacy.synced_at ?? new Date().toISOString(),
74
+ provider: known,
75
+ source: "scrape", // legacy was always scrape
76
+ model: legacy.model,
77
+ h5_used_pct: legacy.h5_used_pct,
78
+ h5_resets_at: legacy.h5_resets_at,
79
+ weekly_used_pct: legacy.weekly_used_pct,
80
+ weekly_resets_at: legacy.weekly_resets_at,
81
+ };
82
+ return { [known]: rec };
83
+ }
84
+ export class MirrorStore {
85
+ path;
86
+ constructor(path = getMirrorPath()) {
87
+ this.path = path;
88
+ }
89
+ /** Read the mirror record. LEGACY: returns whatever is in the file. */
90
+ read() {
91
+ const raw = readJson(this.path);
92
+ if (!raw || typeof raw !== "object")
93
+ return null;
94
+ return raw;
95
+ }
96
+ /**
97
+ * Read the entire per-provider mirror. Auto-migrates legacy shape
98
+ * on first read after upgrade. Returns null if the file is missing
99
+ * or corrupted.
100
+ */
101
+ readAll() {
102
+ const raw = readJson(this.path);
103
+ if (!raw || typeof raw !== "object")
104
+ return null;
105
+ if (isLegacyShape(raw)) {
106
+ const upgraded = upgradeLegacyRecord(raw);
107
+ if (upgraded) {
108
+ // Persist the upgrade so subsequent reads are fast.
109
+ try {
110
+ writeJson(this.path, upgraded);
111
+ }
112
+ catch {
113
+ // best-effort
114
+ }
115
+ return upgraded;
116
+ }
117
+ // Unknown legacy provider — treat as absent.
118
+ return null;
119
+ }
120
+ // Already per-provider shape.
121
+ const out = {};
122
+ for (const [k, v] of Object.entries(raw)) {
123
+ if (v && typeof v === "object") {
124
+ out[k] = v;
125
+ }
126
+ }
127
+ return Object.keys(out).length > 0 ? out : null;
128
+ }
129
+ /** Read a single provider's record. Returns null if missing. */
130
+ readProvider(provider) {
131
+ const all = this.readAll();
132
+ if (!all)
133
+ return null;
134
+ return all[provider] ?? null;
135
+ }
136
+ /** Write a single provider's record (overwrites just that key). */
137
+ writeProvider(provider, record) {
138
+ const all = this.readAll() ?? {};
139
+ all[provider] = record;
140
+ writeJson(this.path, all);
141
+ }
142
+ /** Write a new mirror record (overwrites). LEGACY: deprecated, use writeProvider. */
143
+ write(record) {
144
+ // If the caller passes a record that already has a provider key,
145
+ // route through writeProvider so we keep the per-provider shape.
146
+ if (record &&
147
+ typeof record === "object" &&
148
+ "provider" in record &&
149
+ typeof record.provider === "string") {
150
+ const r = record;
151
+ const provider = r.provider ?? "minimax";
152
+ const prev = this.readProvider(provider);
153
+ this.writeProvider(provider, {
154
+ synced_at: r.synced_at ?? new Date().toISOString(),
155
+ provider,
156
+ source: prev?.source ?? "scrape",
157
+ model: r.model ?? prev?.model,
158
+ h5_used_pct: r.h5_used_pct ?? prev?.h5_used_pct,
159
+ h5_resets_at: r.h5_resets_at ?? prev?.h5_resets_at,
160
+ weekly_used_pct: r.weekly_used_pct ?? prev?.weekly_used_pct,
161
+ weekly_resets_at: r.weekly_resets_at ?? prev?.weekly_resets_at,
162
+ });
163
+ return;
164
+ }
165
+ writeJson(this.path, record);
166
+ }
167
+ /** Returns "fresh" | "stale" | "expired" based on age. */
168
+ freshness(record, nowMs) {
169
+ if (!record || !record.synced_at)
170
+ return "missing";
171
+ const syncedMs = Date.parse(record.synced_at);
172
+ if (isNaN(syncedMs))
173
+ return "missing";
174
+ const ageMs = nowMs - syncedMs;
175
+ if (ageMs < STALE_WARN_MS)
176
+ return "fresh";
177
+ if (ageMs < STALE_ERROR_MS)
178
+ return "stale";
179
+ return "expired";
180
+ }
181
+ /** True if data is too stale to trust (> 2 hours old). */
182
+ isExpired(record, nowMs) {
183
+ return this.freshness(record, nowMs) === "expired";
184
+ }
185
+ /** Convenience: human-readable age like "5 min ago" or "1 d 2 h ago". */
186
+ ageString(record, nowMs) {
187
+ if (!record || !record.synced_at)
188
+ return "never";
189
+ const syncedMs = Date.parse(record.synced_at);
190
+ if (isNaN(syncedMs))
191
+ return "unknown";
192
+ const delta = nowMs - syncedMs;
193
+ const sec = Math.floor(delta / 1000);
194
+ if (sec < 60)
195
+ return `${sec}s ago`;
196
+ const min = Math.floor(sec / 60);
197
+ if (min < 60)
198
+ return `${min} min ago`;
199
+ const hr = Math.floor(min / 60);
200
+ if (hr < 24)
201
+ return `${hr} h ago`;
202
+ const day = Math.floor(hr / 24);
203
+ return `${day} d ago`;
204
+ }
205
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-harness-runtime",
3
- "version": "0.10.13",
3
+ "version": "0.10.14",
4
4
  "description": "[BETA] Codex-style /usage status + autonomous coding harness for pi. Not production ready — expect breaking changes.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -16,15 +16,17 @@
16
16
  "auth:minimax:scrape": "bun packages/auth/src/run-minimax-auth.ts scrape",
17
17
  "skills:sync": "bun scripts/skills-sync.ts",
18
18
  "skills:sync:check": "bun scripts/skills-sync.ts --check-only",
19
- "build": "for pkg in packages/*/; do [ -f \"${pkg}tsconfig.json\" ] && [ \"$pkg\" != \"packages/provider-router/\" ] && node_modules/.bin/tsc -p \"${pkg}tsconfig.json\" --skipLibCheck > /dev/null 2>&1 || true; done",
19
+ "build": "bun run build:packages && bun run build:root",
20
+ "build:root": "for f in *.ts; do [ -f \"$f\" ] && tsc \"$f\" --outDir . --skipLibCheck --module ESNext --moduleResolution node --target ES2022 > /dev/null 2>&1 || true; done",
21
+ "build:packages": "for pkg in packages/*/; do [ -f \"${pkg}tsconfig.json\" ] && [ \"$pkg\" != \"packages/provider-router/\" ] && tsc -p \"${pkg}tsconfig.json\" --skipLibCheck > /dev/null 2>&1 || true; done",
20
22
  "prepublishOnly": "bun run build"
21
23
  },
22
24
  "bin": {
23
25
  "harness-auth": "packages/auth/src/run-minimax-auth.ts"
24
26
  },
25
27
  "files": [
26
- "*.ts",
27
- "harness/**/*.ts",
28
+ "*.{ts,js}",
29
+ "harness/**/*.{ts,js}",
28
30
  "packages/*/dist/**/*",
29
31
  "packages/*/src/**/*.ts",
30
32
  "skills/**/*",
@@ -0,0 +1,42 @@
1
+ export const PROACTIVE_COMPACT_THRESHOLD = 0.9;
2
+ export const PROACTIVE_COMPACT_HEADROOM_TOKENS = 15_000;
3
+ export const PROACTIVE_COMPACT_COOLDOWN_MS = 10 * 60 * 1000;
4
+ export const MAX_PROACTIVE_COMPACT_FAILURES = 3;
5
+ export const OUTPUT_LIMIT_AUTO_RESUME_LIMIT = 3;
6
+ export const OUTPUT_LIMIT_RESUME_PROMPT = "Output token limit hit. Resume directly — no apology, no recap. Pick up mid-thought if the cut happened there. Break remaining work into smaller pieces.";
7
+ export function shouldTriggerProactiveCompact(usage, options) {
8
+ if (!usage) {
9
+ return false;
10
+ }
11
+ const headroomTokens = options?.headroomTokens ?? PROACTIVE_COMPACT_HEADROOM_TOKENS;
12
+ if (usage.tokens !== null &&
13
+ usage.contextWindow - usage.tokens <= headroomTokens) {
14
+ return true;
15
+ }
16
+ const threshold = options?.threshold ?? PROACTIVE_COMPACT_THRESHOLD;
17
+ return usage.percent !== null && usage.percent >= threshold;
18
+ }
19
+ export function isOutputLimitAssistantMessage(message) {
20
+ if (message.role !== "assistant") {
21
+ return false;
22
+ }
23
+ if (message.stopReason === "length" ||
24
+ message.stopReason === "max_output_tokens" ||
25
+ message.stopReason === "max_tokens") {
26
+ return true;
27
+ }
28
+ return (typeof message.errorMessage === "string" &&
29
+ /reached the maximum output token limit|maximum output token limit|output token limit/i.test(message.errorMessage));
30
+ }
31
+ export function shouldQueueOutputLimitResume(message, resumeAttempts, hasPendingMessages, options) {
32
+ const maxAttempts = options?.maxAttempts ?? OUTPUT_LIMIT_AUTO_RESUME_LIMIT;
33
+ return (isOutputLimitAssistantMessage(message) &&
34
+ resumeAttempts < maxAttempts &&
35
+ !hasPendingMessages);
36
+ }
37
+ export function shouldQueuePostCompactionResume(event, hasPendingMessages, options) {
38
+ if (event.reason === "manual" && options?.force !== true) {
39
+ return false;
40
+ }
41
+ return ((options?.force === true || event.willRetry !== true) && !hasPendingMessages);
42
+ }
package/renderer.js ADDED
@@ -0,0 +1,134 @@
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
+ import { formatDuration, formatRelative, formatTokens, formatUsd, } from "./cli.ts";
12
+ import { FIVE_HOURS_MS, SEVEN_DAYS_MS, computeLocalResetTime, } from "./windows.ts";
13
+ const BAR_WIDTH = 20;
14
+ const FILLED = "█";
15
+ const EMPTY = "░";
16
+ export function renderProgressBar(pct, width = BAR_WIDTH) {
17
+ const clamped = Math.max(0, Math.min(100, pct));
18
+ const filledCount = Math.round((clamped / 100) * width);
19
+ const emptyCount = width - filledCount;
20
+ return "[" + FILLED.repeat(filledCount) + EMPTY.repeat(emptyCount) + "]";
21
+ }
22
+ /** Render the "18% left" style label (what's REMAINING, like Codex). */
23
+ function renderLeftLabel(pct) {
24
+ const left = Math.max(0, Math.min(100, 100 - pct));
25
+ return `${left.toFixed(0)}% left`;
26
+ }
27
+ /** Render the full status block. */
28
+ export function renderStatus(input) {
29
+ const lines = [];
30
+ const divider = "-".repeat(64);
31
+ // --- Header ----------------------------------------------------------
32
+ lines.push("Codex-style usage status for pi");
33
+ lines.push(divider);
34
+ lines.push(` Model: ${input.model ?? "unknown"}`);
35
+ lines.push(` Directory: ${input.cwd}`);
36
+ lines.push("");
37
+ // --- Local tracking -------------------------------------------------
38
+ lines.push(" ① LOCAL TRACKED (ground truth — we count this)");
39
+ lines.push(` This session: ${formatUsd(input.local.lifetime.cost)} · ${formatTokens(input.local.lifetime.tokens)} tokens · ${input.local.lifetime.requests} requests`);
40
+ lines.push(` This 5h: ${formatTokens(input.local.five_h.tokens)} tokens · ${input.local.five_h.requests} requests · ${formatUsd(input.local.five_h.cost)}`);
41
+ lines.push(` This week: ${formatTokens(input.local.weekly.tokens)} tokens · ${input.local.weekly.requests} requests · ${formatUsd(input.local.weekly.cost)}`);
42
+ lines.push(` Lifetime: ${input.local.lifetime.requests} requests · ${formatUsd(input.local.lifetime.cost)}`);
43
+ lines.push("");
44
+ // --- Provider mirror -------------------------------------------------
45
+ if (input.mirror) {
46
+ const fresh = input.mirrorStore.freshness(input.mirror, input.nowMs);
47
+ const freshnessLabel = fresh === "fresh"
48
+ ? "fresh"
49
+ : fresh === "stale"
50
+ ? "stale"
51
+ : fresh === "expired"
52
+ ? "EXPIRED"
53
+ : "missing";
54
+ lines.push(` ② PROVIDER MIRROR (auto-fetched from MiniMax console)`);
55
+ lines.push(` Last sync: ${formatRelative(input.mirror.synced_at, input.nowMs)} [${freshnessLabel}]`);
56
+ lines.push(` Provider: ${input.mirror.provider ?? "unknown"}`);
57
+ // 5h line
58
+ if (input.mirror.h5_used_pct !== undefined) {
59
+ const pct = input.mirror.h5_used_pct;
60
+ const resetStr = input.mirror.h5_resets_at
61
+ ? formatDuration(Date.parse(input.mirror.h5_resets_at) - input.nowMs)
62
+ : "unknown";
63
+ lines.push(` 5h limit: ${renderProgressBar(pct)} ${renderLeftLabel(pct)} (resets in ${resetStr})`);
64
+ }
65
+ else {
66
+ lines.push(` 5h limit: (waiting for next auto refresh)`);
67
+ }
68
+ // Weekly line
69
+ if (input.mirror.weekly_used_pct !== undefined) {
70
+ const pct = input.mirror.weekly_used_pct;
71
+ const resetStr = input.mirror.weekly_resets_at
72
+ ? formatDuration(Date.parse(input.mirror.weekly_resets_at) - input.nowMs)
73
+ : "unknown";
74
+ lines.push(` Weekly limit: ${renderProgressBar(pct)} ${renderLeftLabel(pct)} (resets in ${resetStr})`);
75
+ }
76
+ else {
77
+ lines.push(` Weekly limit: (waiting for next auto refresh)`);
78
+ }
79
+ lines.push("");
80
+ }
81
+ else {
82
+ lines.push(` ② PROVIDER MIRROR`);
83
+ lines.push(` Not synced yet. Auto refresh will populate data from MiniMax console.`);
84
+ lines.push("");
85
+ }
86
+ // --- Local reset times (derived) ------------------------------------
87
+ lines.push(" ③ LOCAL RESET TIMES (derived from your data)");
88
+ const local5hReset = computeLocalResetTime(input.local.five_h, FIVE_HOURS_MS);
89
+ const localWeekReset = computeLocalResetTime(input.local.weekly, SEVEN_DAYS_MS);
90
+ if (local5hReset) {
91
+ const remaining = local5hReset - input.nowMs;
92
+ lines.push(` Local 5h reset: in ${formatDuration(remaining)} (oldest request falls out of window)`);
93
+ }
94
+ else {
95
+ lines.push(` Local 5h reset: no requests in last 5 hours`);
96
+ }
97
+ if (localWeekReset) {
98
+ const remaining = localWeekReset - input.nowMs;
99
+ lines.push(` Local week reset: in ${formatDuration(remaining)} (oldest request falls out of window)`);
100
+ }
101
+ else {
102
+ lines.push(` Local week reset: no requests in last 7 days`);
103
+ }
104
+ // --- Local-vs-mirror divergence -------------------------------------
105
+ if (input.mirror?.h5_used_pct !== undefined) {
106
+ const localPct = input.localFiveHLimitTokens
107
+ ? (input.local.five_h.tokens / input.localFiveHLimitTokens) * 100
108
+ : 0;
109
+ const delta = localPct - input.mirror.h5_used_pct;
110
+ const deltaStr = delta >= 0 ? `+${delta.toFixed(1)}%` : `${delta.toFixed(1)}%`;
111
+ const warning = Math.abs(delta) > 5 ? " ⚠️ divergence > 5%" : "";
112
+ lines.push(` Local-vs-mirror: ${deltaStr}${warning}`);
113
+ }
114
+ // --- Burn rate ------------------------------------------------------
115
+ if (input.mirror?.weekly_used_pct !== undefined &&
116
+ input.mirror.weekly_resets_at) {
117
+ const resetMs = Date.parse(input.mirror.weekly_resets_at);
118
+ const elapsedMs = input.nowMs - resetMs;
119
+ const elapsedDays = elapsedMs / (24 * 60 * 60 * 1000);
120
+ if (elapsedDays > 0) {
121
+ const pctPerDay = input.mirror.weekly_used_pct / elapsedDays;
122
+ const remaining = 100 - input.mirror.weekly_used_pct;
123
+ const daysLeft = pctPerDay > 0 ? remaining / pctPerDay : Infinity;
124
+ const daysLeftStr = daysLeft === Infinity ? "∞" : `${daysLeft.toFixed(1)} d`;
125
+ lines.push(` Burn rate: ${pctPerDay.toFixed(1)}% / day → 100% in ${daysLeftStr}`);
126
+ }
127
+ }
128
+ lines.push(divider);
129
+ lines.push(` Data dir: ${process.env.PI_USAGE_DIR ?? "~/.pi/usage-status"}`);
130
+ lines.push(` Local time: ${new Date(input.nowMs).toISOString()}`);
131
+ lines.push("");
132
+ lines.push(" Run `/usage refresh` to fetch the latest provider mirror now.");
133
+ return lines.join("\n");
134
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Status Line Parsers
3
+ *
4
+ * Keep quota usage data and model context usage separate:
5
+ * - Quota usage data: h5_used_pct / weekly_used_pct
6
+ * - Context window usage: 68.4%/205k
7
+ */
8
+ function parseTokenCount(raw) {
9
+ const trimmed = raw.trim().replace(/,/g, '');
10
+ const match = trimmed.match(/^(\d+(?:\.\d+)?)([kKmMbB]?)$/);
11
+ if (!match)
12
+ return null;
13
+ const value = Number.parseFloat(match[1]);
14
+ if (!Number.isFinite(value))
15
+ return null;
16
+ const suffix = match[2].toLowerCase();
17
+ if (suffix === 'k')
18
+ return Math.round(value * 1_000);
19
+ if (suffix === 'm')
20
+ return Math.round(value * 1_000_000);
21
+ if (suffix === 'b')
22
+ return Math.round(value * 1_000_000_000);
23
+ return Math.round(value);
24
+ }
25
+ /**
26
+ * Parse a quota usage status line such as `5h: 100% left · week: 26% left`.
27
+ * Returns the underlying usage fields used by the mirror: `h5_used_pct` and
28
+ * `weekly_used_pct`.
29
+ */
30
+ export function parseQuotaUsageStatusLine(value) {
31
+ const h5Match = value.match(/5h:\s*(\d+(?:\.\d+)?)%\s*left/i);
32
+ const weeklyMatch = value.match(/week:\s*(\d+(?:\.\d+)?)%\s*left/i);
33
+ if (!h5Match && !weeklyMatch) {
34
+ return null;
35
+ }
36
+ const h5Left = h5Match ? Number.parseFloat(h5Match[1]) : null;
37
+ const weeklyLeft = weeklyMatch ? Number.parseFloat(weeklyMatch[1]) : null;
38
+ return {
39
+ h5UsedPct: h5Left === null || !Number.isFinite(h5Left) ? null : 100 - h5Left,
40
+ weeklyUsedPct: weeklyLeft === null || !Number.isFinite(weeklyLeft)
41
+ ? null
42
+ : 100 - weeklyLeft,
43
+ };
44
+ }
45
+ /**
46
+ * Parse a context-window status line such as `68.4%/205k`.
47
+ * Returns the usage percentage, total context window, and inferred used tokens.
48
+ */
49
+ export function parseContextWindowStatusLine(value) {
50
+ const match = value.match(/(\d+(?:\.\d+)?)%\s*\/\s*([\d,.]+(?:[kKmMbB])?)/);
51
+ if (!match)
52
+ return null;
53
+ const usagePct = Number.parseFloat(match[1]);
54
+ const contextWindowTokens = parseTokenCount(match[2]);
55
+ if (!Number.isFinite(usagePct) || contextWindowTokens === null) {
56
+ return null;
57
+ }
58
+ return {
59
+ usagePct,
60
+ contextWindowTokens,
61
+ usedTokens: Math.round((contextWindowTokens * usagePct) / 100),
62
+ };
63
+ }
package/tracker.js ADDED
@@ -0,0 +1,49 @@
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
+ import { appendJsonl, ensureUsageDir, getUsageLogPath, readJsonl, } from "./cli.ts";
13
+ import { unlinkSync } from "node:fs";
14
+ export class UsageTracker {
15
+ path;
16
+ constructor(path = getUsageLogPath()) {
17
+ this.path = path;
18
+ ensureUsageDir();
19
+ }
20
+ /** Append one usage record. */
21
+ append(record) {
22
+ appendJsonl(this.path, record);
23
+ }
24
+ /** Read all records (newest last). Returns [] if file missing. */
25
+ all() {
26
+ return readJsonl(this.path);
27
+ }
28
+ /** Filter records newer than `sinceMs` (inclusive). */
29
+ since(sinceMs) {
30
+ return this.all().filter((r) => r.ts >= sinceMs);
31
+ }
32
+ /** Filter records within [fromMs, toMs). */
33
+ between(fromMs, toMs) {
34
+ return this.all().filter((r) => r.ts >= fromMs && r.ts < toMs);
35
+ }
36
+ /** Clear all records (testing only). */
37
+ clear() {
38
+ try {
39
+ unlinkSync(this.path);
40
+ }
41
+ catch {
42
+ // ignore
43
+ }
44
+ }
45
+ /** Total record count. */
46
+ count() {
47
+ return this.all().length;
48
+ }
49
+ }
package/windows.js ADDED
@@ -0,0 +1,90 @@
1
+ /**
2
+ * WindowAggregator — compute rolling 5h + weekly windows from local records.
3
+ *
4
+ * We do NOT need provider-reported reset times — we can DERIVE them from our
5
+ * own data: oldest record in window + window duration = when that record
6
+ * will fall out of the window = the "reset" moment for OUR local usage.
7
+ *
8
+ * For provider-mirror reset times, we just read them from MirrorStore.
9
+ */
10
+ export const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
11
+ export const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
12
+ function emptyWindow() {
13
+ return { tokens: 0, requests: 0, cost: 0, oldest_ts: null };
14
+ }
15
+ function rollUp(records) {
16
+ const stats = emptyWindow();
17
+ for (const r of records) {
18
+ const tokens = r.input + r.output + r.cache_read + r.cache_write;
19
+ stats.tokens += tokens;
20
+ stats.requests += 1;
21
+ stats.cost += r.cost;
22
+ if (stats.oldest_ts === null || r.ts < stats.oldest_ts) {
23
+ stats.oldest_ts = r.ts;
24
+ }
25
+ }
26
+ return stats;
27
+ }
28
+ /** Compute reset time for a rolling window from its oldest record. */
29
+ export function computeLocalResetTime(stats, windowMs) {
30
+ if (stats.oldest_ts === null)
31
+ return null;
32
+ return stats.oldest_ts + windowMs;
33
+ }
34
+ /** Filter records within a rolling window. */
35
+ function withinWindow(records, nowMs, windowMs) {
36
+ const cutoff = nowMs - windowMs;
37
+ return records.filter((r) => r.ts >= cutoff);
38
+ }
39
+ /** UTC midnight (ms) of the day containing `nowMs`. */
40
+ function utcMidnight(nowMs) {
41
+ const d = new Date(nowMs);
42
+ return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
43
+ }
44
+ /** Monday 00:00 UTC of the week containing `nowMs`. */
45
+ function weeklyCutoff(nowMs) {
46
+ const d = new Date(nowMs);
47
+ const day = d.getUTCDay(); // 0=Sun..6=Sat
48
+ const diff = (day + 6) % 7; // days since Monday
49
+ const mondayMidnight = utcMidnight(nowMs) - diff * 86400 * 1000;
50
+ return mondayMidnight;
51
+ }
52
+ /** Main entry: aggregate all windows from raw records. */
53
+ export function aggregateWindows(records, nowMs = Date.now()) {
54
+ const five_h = rollUp(withinWindow(records, nowMs, FIVE_HOURS_MS));
55
+ const weekly = rollUp(withinWindow(records, nowMs, SEVEN_DAYS_MS));
56
+ const today = rollUp(records.filter((r) => r.ts >= utcMidnight(nowMs)));
57
+ const lifetime = rollUp(records);
58
+ return { five_h, weekly, today, lifetime };
59
+ }
60
+ /**
61
+ * Compute burn rate (% per day) from the weekly mirror.
62
+ * mirror.weekly_used_pct used, time since weekly reset = nowMs - weekly_resets_at.
63
+ */
64
+ export function computeBurnRate(mirrorWeeklyUsedPct, weeklyResetsAtMs, nowMs) {
65
+ const elapsedMs = nowMs - weeklyResetsAtMs;
66
+ if (elapsedMs <= 0) {
67
+ return { pct_per_day: 0, days_until_full: null };
68
+ }
69
+ const elapsedDays = elapsedMs / (24 * 60 * 60 * 1000);
70
+ const pctPerDay = mirrorWeeklyUsedPct / elapsedDays;
71
+ const remainingPct = 100 - mirrorWeeklyUsedPct;
72
+ if (pctPerDay <= 0) {
73
+ return { pct_per_day: 0, days_until_full: null };
74
+ }
75
+ const daysUntilFull = remainingPct / pctPerDay;
76
+ return { pct_per_day: pctPerDay, days_until_full: daysUntilFull };
77
+ }
78
+ /**
79
+ * Compute the divergence between local-tracked usage and provider mirror.
80
+ * Returns the difference in percentage points (positive = local > mirror).
81
+ *
82
+ * This is a rough check: if local tracking shows 5% used but provider
83
+ * mirror shows 30%, you know other clients (or pre-existing quota) are
84
+ * using the same provider account.
85
+ */
86
+ export function computeLocalVsMirrorDelta(localFiveHPct, mirrorFiveHPct) {
87
+ if (mirrorFiveHPct === undefined)
88
+ return null;
89
+ return localFiveHPct - mirrorFiveHPct;
90
+ }