@danypops/jittor 0.2.1 → 0.4.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/README.md +9 -1
- package/extension/src/index.ts +56 -2
- package/extension/src/settings-tui.ts +153 -0
- package/extension/src/settings.ts +32 -4
- package/extension/src/usage.ts +67 -34
- package/package.json +1 -1
- package/src/constants.ts +1 -0
- package/src/domain/usage.ts +26 -22
package/README.md
CHANGED
|
@@ -54,7 +54,15 @@ The on/off choice persists privately in `$XDG_CONFIG_HOME/jittor/extension.json`
|
|
|
54
54
|
|
|
55
55
|
Jittor observes finalized Codex assistant errors through Pi's public message lifecycle, classifies only bounded error metadata, and waits for `agent_settled` before acting. That boundary guarantees Pi's built-in retry, compaction retry, and queued follow-up work has finished. A transient concurrency, rate-limit, overload, or transport failure then schedules one hidden follow-up with Retry-After-aware capped jitter. Recovery is limited to three attempts per ten-minute window, never overlaps pending Pi messages, resets after success, and is canceled by human input or session shutdown. Quota, authentication, invalid-request, unknown, and aborted failures remain terminal. Raw provider payloads are never retained or injected.
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
### Settings
|
|
58
|
+
|
|
59
|
+
Run `/jittor settings` for one keyboard-navigable TUI covering routing enforcement, the informational footer, Codex recovery, and all four token-budget thresholds. It uses explicit ON/OFF and configured/not-configured labels, remains bounded on narrow terminals, confirms weaker enforcement and recovery changes, and applies enforcement enablement through the same readiness checks as `/jittor on`. Existing non-TUI commands remain available for automation.
|
|
60
|
+
|
|
61
|
+
### Usage graphs
|
|
62
|
+
|
|
63
|
+
Run `/jittor usage` for a colored Unicode cumulative token graph with X/Y axes, provider/model series, input/output/cache totals, refresh, and explicit **Hourly**, **Daily**, **Weekly**, and **Monthly** periods. Left/Right changes period and `r` refreshes. Usage is persisted by the daemon from finalized Pi assistant messages.
|
|
64
|
+
|
|
65
|
+
Token-budget thresholds are optional and must be configured by the user; Jittor never infers a token allowance from Codex or another provider's subscription percentage. Configure or clear one period with `/jittor usage budget <hourly|daily|weekly|monthly> <positive-tokens|off>`, and inspect all four with `/jittor usage budget`. A configured budget appears as a horizontal threshold on the cumulative graph with explicit remaining or **OVER BUDGET** state. These private settings persist in `$XDG_CONFIG_HOME/jittor/extension.json` (or `~/.config/jittor/extension.json`).
|
|
58
66
|
|
|
59
67
|
See [`docs/CALIBRATION.md`](docs/CALIBRATION.md) for thresholds and rollback, and [`docs/USAGE_PRIOR_ART.md`](docs/USAGE_PRIOR_ART.md) for the chart design research.
|
|
60
68
|
|
package/extension/src/index.ts
CHANGED
|
@@ -12,12 +12,14 @@ import {
|
|
|
12
12
|
} from "../../src/constants.ts";
|
|
13
13
|
import { CodexRecoveryPolicy, classifyCodexFailure, type CodexFailureKind, type CodexFailureMetadata } from "../../src/domain/codex-recovery.ts";
|
|
14
14
|
import type { MetricObservation, StoredMetricObservation } from "../../src/domain/metric.ts";
|
|
15
|
+
import { USAGE_PERIODS, type UsagePeriod } from "../../src/domain/usage.ts";
|
|
15
16
|
import type { PolicyDecision, Route } from "../../src/policy.ts";
|
|
16
17
|
import type { RouterStatus } from "../../src/ports/router-controller.ts";
|
|
17
18
|
import { parseCodexRateLimitHeaders } from "../../src/providers/codex.ts";
|
|
18
19
|
import { installIntegratedFooter, type IntegratedFooterState } from "./footer.ts";
|
|
19
20
|
import { callJittor } from "./service-client.ts";
|
|
20
|
-
import { persistentEnforcementControl, type CodexRecoveryControl, type EnforcementControl } from "./settings.ts";
|
|
21
|
+
import { persistentEnforcementControl, type CodexRecoveryControl, type EnforcementControl, type UsageBudgetControl } from "./settings.ts";
|
|
22
|
+
import { showSettingsPanel } from "./settings-tui.ts";
|
|
21
23
|
import { buildFooterBudget, formatFooterStatus, showJittorPanel } from "./tui.ts";
|
|
22
24
|
import { showUsagePanel } from "./usage.ts";
|
|
23
25
|
|
|
@@ -48,6 +50,16 @@ const SYSTEM_RECOVERY_RUNTIME: CodexRecoveryRuntime = {
|
|
|
48
50
|
clearTimeout(handle) { clearTimeout(handle as ReturnType<typeof setTimeout>); },
|
|
49
51
|
};
|
|
50
52
|
|
|
53
|
+
function usageBudgetControl(enforcement: EnforcementControl): UsageBudgetControl {
|
|
54
|
+
const candidate = enforcement as EnforcementControl & Partial<UsageBudgetControl>;
|
|
55
|
+
return typeof candidate.getUsageTokenBudget === "function" && typeof candidate.setUsageTokenBudget === "function"
|
|
56
|
+
? {
|
|
57
|
+
getUsageTokenBudget: (period) => candidate.getUsageTokenBudget!(period),
|
|
58
|
+
setUsageTokenBudget: (period, tokens) => candidate.setUsageTokenBudget!(period, tokens),
|
|
59
|
+
}
|
|
60
|
+
: { getUsageTokenBudget: () => undefined, setUsageTokenBudget() {} };
|
|
61
|
+
}
|
|
62
|
+
|
|
51
63
|
function recoveryControl(enforcement: EnforcementControl): CodexRecoveryControl {
|
|
52
64
|
const candidate = enforcement as EnforcementControl & Partial<CodexRecoveryControl>;
|
|
53
65
|
const set = (candidate as Partial<CodexRecoveryControl>).setCodexRecoveryEnabled;
|
|
@@ -214,6 +226,7 @@ export function registerJittorExtension(
|
|
|
214
226
|
recoveryRuntime: CodexRecoveryRuntime = SYSTEM_RECOVERY_RUNTIME,
|
|
215
227
|
): void {
|
|
216
228
|
const footerState: IntegratedFooterState = { providerBudget: null };
|
|
229
|
+
const usageBudgets = usageBudgetControl(enforcement);
|
|
217
230
|
const recoveryPolicy = new CodexRecoveryPolicy({
|
|
218
231
|
baseDelayMs: CODEX_RECOVERY_BASE_DELAY_MS,
|
|
219
232
|
maxDelayMs: CODEX_RECOVERY_MAX_DELAY_MS,
|
|
@@ -360,8 +373,49 @@ export function registerJittorExtension(
|
|
|
360
373
|
ctx.ui.notify("Jittor informational footer enabled; routing enforcement is unchanged.", "info");
|
|
361
374
|
return;
|
|
362
375
|
}
|
|
376
|
+
if (action === "settings") {
|
|
377
|
+
await showSettingsPanel(ctx, enforcement, codexRecovery, usageBudgets, {
|
|
378
|
+
setEnforcement: async (enabled) => enabled ? enable(ctx) : disable(ctx),
|
|
379
|
+
setFooter: async (enabled) => {
|
|
380
|
+
enforcement.setFooterEnabled(enabled);
|
|
381
|
+
showFooter(ctx);
|
|
382
|
+
if (enabled) await refreshFooter(client, footerState).catch(() => undefined);
|
|
383
|
+
},
|
|
384
|
+
setRecovery: (enabled) => {
|
|
385
|
+
if (!enabled) cancelRecovery(true);
|
|
386
|
+
codexRecovery.setCodexRecoveryEnabled(enabled);
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
if (action === "usage budget" || action.startsWith("usage budget ")) {
|
|
392
|
+
const [, , periodText, valueText] = action.split(/\s+/);
|
|
393
|
+
const period = USAGE_PERIODS.some((candidate) => candidate.id === periodText) ? periodText as UsagePeriod : undefined;
|
|
394
|
+
if (!period) {
|
|
395
|
+
const values = USAGE_PERIODS.map(({ id, label }) => `${label}: ${usageBudgets.getUsageTokenBudget(id)?.toLocaleString() ?? "not configured"}`).join(" · ");
|
|
396
|
+
ctx.ui.notify(`Token budgets · ${values}`, "info");
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
if (valueText === undefined) {
|
|
400
|
+
ctx.ui.notify(`${USAGE_PERIODS.find((candidate) => candidate.id === period)!.label} token budget: ${usageBudgets.getUsageTokenBudget(period)?.toLocaleString() ?? "not configured"}`, "info");
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
if (valueText === "off" || valueText === "clear") {
|
|
404
|
+
usageBudgets.setUsageTokenBudget(period, undefined);
|
|
405
|
+
ctx.ui.notify(`${USAGE_PERIODS.find((candidate) => candidate.id === period)!.label} token budget cleared.`, "info");
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
const tokens = Number(valueText.replaceAll(",", ""));
|
|
409
|
+
if (!Number.isFinite(tokens) || tokens <= 0) {
|
|
410
|
+
ctx.ui.notify("Usage: /jittor usage budget <hourly|daily|weekly|monthly> <positive-tokens|off>", "warning");
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
usageBudgets.setUsageTokenBudget(period, tokens);
|
|
414
|
+
ctx.ui.notify(`${USAGE_PERIODS.find((candidate) => candidate.id === period)!.label} token budget set to ${tokens.toLocaleString()} tokens.`, "info");
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
363
417
|
if (action === "usage") {
|
|
364
|
-
await showUsagePanel(ctx, client);
|
|
418
|
+
await showUsagePanel(ctx, client, usageBudgets);
|
|
365
419
|
return;
|
|
366
420
|
}
|
|
367
421
|
if (!enforcement.isEnabled()) {
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { USAGE_PERIODS, type UsagePeriod } from "../../src/domain/usage.ts";
|
|
4
|
+
import type { CodexRecoveryControl, EnforcementControl, UsageBudgetControl } from "./settings.ts";
|
|
5
|
+
|
|
6
|
+
export interface SettingsSnapshot {
|
|
7
|
+
enforcementEnabled: boolean;
|
|
8
|
+
footerEnabled: boolean;
|
|
9
|
+
codexRecoveryEnabled: boolean;
|
|
10
|
+
usageTokenBudgets: Partial<Record<UsagePeriod, number>>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface SettingsTheme {
|
|
14
|
+
fg(color: "accent" | "success" | "warning" | "error" | "muted" | "dim" | "borderMuted", text: string): string;
|
|
15
|
+
bold(text: string): string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type SettingsKey = "enforcement" | "footer" | "recovery" | `budget-${UsagePeriod}`;
|
|
19
|
+
type SettingsAction = { kind: "activate"; key: SettingsKey } | { kind: "close" };
|
|
20
|
+
|
|
21
|
+
export interface SettingsEffects {
|
|
22
|
+
setEnforcement(enabled: boolean): void | Promise<void>;
|
|
23
|
+
setFooter(enabled: boolean): void | Promise<void>;
|
|
24
|
+
setRecovery(enabled: boolean): void | Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const SETTINGS_KEYS: SettingsKey[] = [
|
|
28
|
+
"enforcement",
|
|
29
|
+
"footer",
|
|
30
|
+
"recovery",
|
|
31
|
+
...USAGE_PERIODS.map(({ id }) => `budget-${id}` as const),
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
function state(enabled: boolean, theme: SettingsTheme): string {
|
|
35
|
+
return enabled ? theme.fg("success", "ON") : theme.fg("muted", "OFF");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function budgetLabel(period: UsagePeriod, snapshot: SettingsSnapshot): string {
|
|
39
|
+
const value = snapshot.usageTokenBudgets[period];
|
|
40
|
+
return value === undefined ? "not configured" : `${value.toLocaleString()} tokens`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function rowText(key: SettingsKey, snapshot: SettingsSnapshot, theme: SettingsTheme): string {
|
|
44
|
+
if (key === "enforcement") return `Routing enforcement ${state(snapshot.enforcementEnabled, theme)}`;
|
|
45
|
+
if (key === "footer") return `Informational footer ${state(snapshot.footerEnabled, theme)}`;
|
|
46
|
+
if (key === "recovery") return `Codex recovery ${state(snapshot.codexRecoveryEnabled, theme)}`;
|
|
47
|
+
const period = key.slice("budget-".length) as UsagePeriod;
|
|
48
|
+
return `${USAGE_PERIODS.find((candidate) => candidate.id === period)!.label} token budget ${budgetLabel(period, snapshot)}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function settingsSnapshot(
|
|
52
|
+
enforcement: EnforcementControl,
|
|
53
|
+
recovery: CodexRecoveryControl,
|
|
54
|
+
budgets: UsageBudgetControl,
|
|
55
|
+
): SettingsSnapshot {
|
|
56
|
+
return {
|
|
57
|
+
enforcementEnabled: enforcement.isEnabled(),
|
|
58
|
+
footerEnabled: enforcement.isFooterEnabled(),
|
|
59
|
+
codexRecoveryEnabled: recovery.isCodexRecoveryEnabled(),
|
|
60
|
+
usageTokenBudgets: Object.fromEntries(USAGE_PERIODS.map(({ id }) => [id, budgets.getUsageTokenBudget(id)])),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function renderSettingsView(snapshot: SettingsSnapshot, selected: number, width: number, theme: SettingsTheme): string[] {
|
|
65
|
+
const safeWidth = Math.max(20, width);
|
|
66
|
+
const lines = [
|
|
67
|
+
theme.bold("Jittor Settings"),
|
|
68
|
+
theme.fg("dim", "Token budgets are user values; provider quotas remain separate."),
|
|
69
|
+
"",
|
|
70
|
+
];
|
|
71
|
+
for (let index = 0; index < SETTINGS_KEYS.length; index += 1) {
|
|
72
|
+
const selectedRow = index === selected;
|
|
73
|
+
const prefix = selectedRow ? theme.fg("accent", "› ") : " ";
|
|
74
|
+
lines.push(`${prefix}${rowText(SETTINGS_KEYS[index]!, snapshot, theme)}`);
|
|
75
|
+
}
|
|
76
|
+
lines.push("", theme.fg("dim", "↑/↓ select · Enter edit · Esc close"));
|
|
77
|
+
return lines.map((line) => visibleWidth(line) <= safeWidth ? line : truncateToWidth(line, safeWidth, "…"));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function plainTheme(): SettingsTheme {
|
|
81
|
+
return { fg: (_color, text) => text, bold: (text) => text };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function editBudget(ctx: ExtensionCommandContext, budgets: UsageBudgetControl, period: UsagePeriod): Promise<void> {
|
|
85
|
+
const label = USAGE_PERIODS.find((candidate) => candidate.id === period)!.label;
|
|
86
|
+
const current = budgets.getUsageTokenBudget(period);
|
|
87
|
+
const input = await ctx.ui.input(`${label} token budget`, current?.toLocaleString() ?? "positive token count or off");
|
|
88
|
+
if (input === undefined) return;
|
|
89
|
+
const normalized = input.trim().toLowerCase();
|
|
90
|
+
if (normalized === "off" || normalized === "clear") {
|
|
91
|
+
budgets.setUsageTokenBudget(period, undefined);
|
|
92
|
+
ctx.ui.notify(`${label} token budget cleared.`, "info");
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const tokens = Number(normalized.replaceAll(",", ""));
|
|
96
|
+
if (!Number.isFinite(tokens) || tokens <= 0) {
|
|
97
|
+
ctx.ui.notify("Enter a positive token count, or `off` to clear this threshold.", "warning");
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
budgets.setUsageTokenBudget(period, tokens);
|
|
101
|
+
ctx.ui.notify(`${label} token budget set to ${tokens.toLocaleString()} tokens.`, "info");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function showSettingsPanel(
|
|
105
|
+
ctx: ExtensionCommandContext,
|
|
106
|
+
enforcement: EnforcementControl,
|
|
107
|
+
recovery: CodexRecoveryControl,
|
|
108
|
+
budgets: UsageBudgetControl,
|
|
109
|
+
effects: SettingsEffects = {
|
|
110
|
+
setEnforcement: (enabled) => enforcement.setEnabled(enabled),
|
|
111
|
+
setFooter: (enabled) => enforcement.setFooterEnabled(enabled),
|
|
112
|
+
setRecovery: (enabled) => recovery.setCodexRecoveryEnabled(enabled),
|
|
113
|
+
},
|
|
114
|
+
): Promise<void> {
|
|
115
|
+
if (ctx.mode !== "tui") {
|
|
116
|
+
ctx.ui.notify(renderSettingsView(settingsSnapshot(enforcement, recovery, budgets), -1, 100, plainTheme()).slice(0, -2).join("\n"), "info");
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
for (;;) {
|
|
120
|
+
const snapshot = settingsSnapshot(enforcement, recovery, budgets);
|
|
121
|
+
const action = await ctx.ui.custom<SettingsAction>((tui, theme, _keybindings, done) => {
|
|
122
|
+
let selected = 0;
|
|
123
|
+
return {
|
|
124
|
+
invalidate() {},
|
|
125
|
+
render(width: number): string[] { return renderSettingsView(snapshot, selected, width, theme); },
|
|
126
|
+
handleInput(data: string): void {
|
|
127
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c") || data === "q") done({ kind: "close" });
|
|
128
|
+
else if (matchesKey(data, "up")) { selected = (selected - 1 + SETTINGS_KEYS.length) % SETTINGS_KEYS.length; tui.requestRender(); }
|
|
129
|
+
else if (matchesKey(data, "down")) { selected = (selected + 1) % SETTINGS_KEYS.length; tui.requestRender(); }
|
|
130
|
+
else if (matchesKey(data, "return") || matchesKey(data, "enter") || matchesKey(data, "space")) done({ kind: "activate", key: SETTINGS_KEYS[selected]! });
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
});
|
|
134
|
+
if (!action || action.kind === "close") return;
|
|
135
|
+
if (action.key === "enforcement") {
|
|
136
|
+
if (enforcement.isEnabled()) {
|
|
137
|
+
if (await ctx.ui.confirm("Disable routing enforcement?", "Jittor will remain monitor-only and will no longer block unsafe provider requests.")) await effects.setEnforcement(false);
|
|
138
|
+
} else await effects.setEnforcement(true);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (action.key === "footer") {
|
|
142
|
+
await effects.setFooter(!enforcement.isFooterEnabled());
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (action.key === "recovery") {
|
|
146
|
+
if (!recovery.isCodexRecoveryEnabled()) {
|
|
147
|
+
if (await ctx.ui.confirm("Enable Codex recovery?", "Jittor may start bounded hidden retries only after transient Codex failures fully settle.")) await effects.setRecovery(true);
|
|
148
|
+
} else await effects.setRecovery(false);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
await editBudget(ctx, budgets, action.key.slice("budget-".length) as UsagePeriod);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { JITTOR_EXTENSION_SETTINGS_FILENAME, JITTOR_STATE_DIRECTORY } from "../../src/constants.ts";
|
|
4
|
+
import { USAGE_PERIODS, type UsagePeriod } from "../../src/domain/usage.ts";
|
|
4
5
|
|
|
5
6
|
export interface EnforcementControl {
|
|
6
7
|
isEnabled(): boolean;
|
|
@@ -14,14 +15,31 @@ export interface CodexRecoveryControl {
|
|
|
14
15
|
setCodexRecoveryEnabled(enabled: boolean): void;
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
export interface
|
|
18
|
-
|
|
18
|
+
export interface UsageBudgetControl {
|
|
19
|
+
getUsageTokenBudget(period: UsagePeriod): number | undefined;
|
|
20
|
+
setUsageTokenBudget(period: UsagePeriod, tokens: number | undefined): void;
|
|
19
21
|
}
|
|
20
22
|
|
|
23
|
+
export interface PersistentExtensionControl extends EnforcementControl, CodexRecoveryControl, UsageBudgetControl {}
|
|
24
|
+
|
|
21
25
|
interface ExtensionSettings {
|
|
22
26
|
enforcementEnabled: boolean;
|
|
23
27
|
footerEnabled: boolean;
|
|
24
28
|
codexRecoveryEnabled: boolean;
|
|
29
|
+
usageTokenBudgets: Partial<Record<UsagePeriod, number>>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function defaultSettings(): ExtensionSettings {
|
|
33
|
+
return { enforcementEnabled: true, footerEnabled: true, codexRecoveryEnabled: false, usageTokenBudgets: {} };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseUsageTokenBudgets(value: unknown): Partial<Record<UsagePeriod, number>> {
|
|
37
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
|
|
38
|
+
const record = value as Record<string, unknown>;
|
|
39
|
+
return Object.fromEntries(USAGE_PERIODS.flatMap(({ id }) => {
|
|
40
|
+
const tokens = record[id];
|
|
41
|
+
return typeof tokens === "number" && Number.isFinite(tokens) && tokens > 0 ? [[id, tokens]] : [];
|
|
42
|
+
})) as Partial<Record<UsagePeriod, number>>;
|
|
25
43
|
}
|
|
26
44
|
|
|
27
45
|
function settingsPath(env: Record<string, string | undefined> = process.env): string {
|
|
@@ -32,15 +50,16 @@ function settingsPath(env: Record<string, string | undefined> = process.env): st
|
|
|
32
50
|
function loadSettings(path: string): ExtensionSettings {
|
|
33
51
|
try {
|
|
34
52
|
const value = JSON.parse(readFileSync(path, "utf8")) as unknown;
|
|
35
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) return
|
|
53
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return defaultSettings();
|
|
36
54
|
const record = value as Record<string, unknown>;
|
|
37
55
|
return {
|
|
38
56
|
enforcementEnabled: record["enforcementEnabled"] !== false,
|
|
39
57
|
footerEnabled: record["footerEnabled"] !== false,
|
|
40
58
|
codexRecoveryEnabled: record["codexRecoveryEnabled"] === true,
|
|
59
|
+
usageTokenBudgets: parseUsageTokenBudgets(record["usageTokenBudgets"]),
|
|
41
60
|
};
|
|
42
61
|
} catch {
|
|
43
|
-
return
|
|
62
|
+
return defaultSettings();
|
|
44
63
|
}
|
|
45
64
|
}
|
|
46
65
|
|
|
@@ -71,5 +90,14 @@ export function persistentEnforcementControl(env: Record<string, string | undefi
|
|
|
71
90
|
settings.codexRecoveryEnabled = value;
|
|
72
91
|
persistSettings(path, settings);
|
|
73
92
|
},
|
|
93
|
+
getUsageTokenBudget(period): number | undefined {
|
|
94
|
+
return settings.usageTokenBudgets[period];
|
|
95
|
+
},
|
|
96
|
+
setUsageTokenBudget(period, tokens): void {
|
|
97
|
+
if (tokens !== undefined && (!Number.isFinite(tokens) || tokens <= 0)) throw new Error("usage token budget must be a positive finite number");
|
|
98
|
+
if (tokens === undefined) delete settings.usageTokenBudgets[period];
|
|
99
|
+
else settings.usageTokenBudgets[period] = tokens;
|
|
100
|
+
persistSettings(path, settings);
|
|
101
|
+
},
|
|
74
102
|
};
|
|
75
103
|
}
|
package/extension/src/usage.ts
CHANGED
|
@@ -3,16 +3,18 @@ import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tu
|
|
|
3
3
|
import { USAGE_CHART_HEIGHT, USAGE_TOKEN_QUERY_LIMIT, USAGE_Y_AXIS_WIDTH } from "../../src/constants.ts";
|
|
4
4
|
import type { StoredMetricObservation } from "../../src/domain/metric.ts";
|
|
5
5
|
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
buildUsageGraph,
|
|
7
|
+
USAGE_PERIODS,
|
|
8
|
+
usagePeriod,
|
|
9
|
+
usagePeriodStart,
|
|
9
10
|
type UsageBucket,
|
|
10
|
-
type
|
|
11
|
-
type
|
|
11
|
+
type UsageGraph,
|
|
12
|
+
type UsagePeriod,
|
|
12
13
|
} from "../../src/domain/usage.ts";
|
|
14
|
+
import type { UsageBudgetControl } from "./settings.ts";
|
|
13
15
|
import type { JittorPanelClient } from "./tui.ts";
|
|
14
16
|
|
|
15
|
-
type UsageAction = "
|
|
17
|
+
type UsageAction = "period-prev" | "period-next" | "refresh" | "close";
|
|
16
18
|
type UsageColor = "accent" | "success" | "warning" | "error" | "thinkingText" | "muted" | "dim" | "borderMuted";
|
|
17
19
|
|
|
18
20
|
export interface UsageTheme {
|
|
@@ -26,7 +28,7 @@ const PARTIAL_BLOCKS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
|
|
|
26
28
|
function compact(value: number): string {
|
|
27
29
|
if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(value >= 10_000_000_000 ? 0 : 1)}B`;
|
|
28
30
|
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(value >= 10_000_000 ? 0 : 1)}M`;
|
|
29
|
-
if (value >= 1_000) return `${(value / 1_000).toFixed(value >= 10_000 ? 0 : 1)}k`;
|
|
31
|
+
if (value >= 1_000) return `${(value / 1_000).toFixed(value >= 10_000 || value % 1_000 === 0 ? 0 : 1)}k`;
|
|
30
32
|
return String(Math.round(value));
|
|
31
33
|
}
|
|
32
34
|
|
|
@@ -51,7 +53,7 @@ function mergeBuckets(buckets: UsageBucket[], maximum: number): UsageBucket[] {
|
|
|
51
53
|
return result;
|
|
52
54
|
}
|
|
53
55
|
|
|
54
|
-
function seriesAt(bucket: UsageBucket, chart:
|
|
56
|
+
function seriesAt(bucket: UsageBucket, chart: UsageGraph, tokenHeight: number): number {
|
|
55
57
|
let cumulative = 0;
|
|
56
58
|
for (let index = 0; index < chart.series.length; index += 1) {
|
|
57
59
|
cumulative += bucket.series[chart.series[index]!.key] ?? 0;
|
|
@@ -60,14 +62,14 @@ function seriesAt(bucket: UsageBucket, chart: UsageHistogram, tokenHeight: numbe
|
|
|
60
62
|
return Math.max(0, chart.series.length - 1);
|
|
61
63
|
}
|
|
62
64
|
|
|
63
|
-
function
|
|
65
|
+
function formatPeriodPoint(value: number, period: UsagePeriod): string {
|
|
64
66
|
const date = new Date(value);
|
|
65
|
-
if (
|
|
67
|
+
if (period === "hourly" || period === "daily") return date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
|
66
68
|
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
|
67
69
|
}
|
|
68
70
|
|
|
69
|
-
function axisLabels(start: number, end: number,
|
|
70
|
-
const labels = [
|
|
71
|
+
function axisLabels(start: number, end: number, period: UsagePeriod, width: number): string {
|
|
72
|
+
const labels = [formatPeriodPoint(start, period), formatPeriodPoint(start + (end - start) / 2, period), formatPeriodPoint(end, period)];
|
|
71
73
|
const positions = [0, Math.max(0, Math.floor((width - labels[1]!.length) / 2)), Math.max(0, width - labels[2]!.length)];
|
|
72
74
|
const characters = Array.from({ length: width }, () => " ");
|
|
73
75
|
for (let labelIndex = 0; labelIndex < labels.length; labelIndex += 1) {
|
|
@@ -82,26 +84,51 @@ function plainTheme(): UsageTheme {
|
|
|
82
84
|
return { fg: (_color, text) => text, bold: (text) => text };
|
|
83
85
|
}
|
|
84
86
|
|
|
85
|
-
export function
|
|
87
|
+
export function renderUsageGraph(chart: UsageGraph, width: number, theme: UsageTheme, tokenBudget?: number): string[] {
|
|
86
88
|
const safeWidth = Math.max(20, width);
|
|
87
89
|
const chartColumns = Math.max(1, Math.floor((safeWidth - USAGE_Y_AXIS_WIDTH - 1) / 2));
|
|
88
|
-
const
|
|
90
|
+
const increments = mergeBuckets(chart.buckets, chartColumns);
|
|
91
|
+
const runningSeries: Record<string, number> = {};
|
|
92
|
+
let runningTotal = 0;
|
|
93
|
+
const buckets = increments.map((bucket) => {
|
|
94
|
+
runningTotal += bucket.total;
|
|
95
|
+
for (const [key, value] of Object.entries(bucket.series)) runningSeries[key] = (runningSeries[key] ?? 0) + value;
|
|
96
|
+
return { ...bucket, total: runningTotal, series: { ...runningSeries } };
|
|
97
|
+
});
|
|
89
98
|
const barStep = buckets.length * 2 <= safeWidth - USAGE_Y_AXIS_WIDTH ? 2 : 1;
|
|
90
99
|
const plotWidth = buckets.length * barStep;
|
|
91
|
-
const
|
|
100
|
+
const budget = typeof tokenBudget === "number" && Number.isFinite(tokenBudget) && tokenBudget > 0 ? tokenBudget : undefined;
|
|
101
|
+
const maximum = Math.max(chart.totalTokens, budget ?? 0);
|
|
102
|
+
const observed = chart.truncated ? `at least ${compact(chart.totalTokens)}` : compact(chart.totalTokens);
|
|
103
|
+
const budgetState = budget === undefined
|
|
104
|
+
? `${observed} tokens · budget not configured${chart.truncated ? " · query limit reached" : ""}`
|
|
105
|
+
: chart.totalTokens > budget
|
|
106
|
+
? `${observed} / ${compact(budget)} budget · OVER BUDGET by ${chart.truncated ? "at least " : ""}${compact(chart.totalTokens - budget)}`
|
|
107
|
+
: chart.truncated
|
|
108
|
+
? `${observed} / ${compact(budget)} budget · state unknown · query limit reached`
|
|
109
|
+
: `${observed} / ${compact(budget)} budget · ${compact(budget - chart.totalTokens)} remaining`;
|
|
92
110
|
const lines = [
|
|
93
|
-
truncateToWidth(theme.bold(
|
|
94
|
-
truncateToWidth(
|
|
111
|
+
truncateToWidth(theme.bold(`${usagePeriod(chart.period).label} token usage`), safeWidth, ""),
|
|
112
|
+
truncateToWidth(budgetState, safeWidth, "…"),
|
|
113
|
+
truncateToWidth(`input ${compact(chart.breakdown.input)} · output ${compact(chart.breakdown.output)} · cache ${compact(chart.breakdown.cacheRead + chart.breakdown.cacheWrite)}`, safeWidth, "…"),
|
|
95
114
|
"",
|
|
96
115
|
];
|
|
97
116
|
if (maximum === 0) {
|
|
98
|
-
lines.push(theme.fg("dim", "No recorded Pi token usage in this
|
|
117
|
+
lines.push(theme.fg("dim", "No recorded Pi token usage in this period."));
|
|
99
118
|
return lines.map((line) => truncateToWidth(line, safeWidth, "…"));
|
|
100
119
|
}
|
|
101
120
|
|
|
102
121
|
for (let row = 0; row < USAGE_CHART_HEIGHT; row += 1) {
|
|
103
122
|
const fromBottom = USAGE_CHART_HEIGHT - row - 1;
|
|
104
|
-
const
|
|
123
|
+
const lower = maximum * fromBottom / USAGE_CHART_HEIGHT;
|
|
124
|
+
const upper = maximum * (fromBottom + 1) / USAGE_CHART_HEIGHT;
|
|
125
|
+
const thresholdRow = budget !== undefined && budget > lower && budget <= upper;
|
|
126
|
+
const label = thresholdRow ? compact(budget) : row === 0 ? compact(maximum) : row === Math.floor(USAGE_CHART_HEIGHT / 2) ? compact(maximum / 2) : "";
|
|
127
|
+
if (thresholdRow) {
|
|
128
|
+
const color = chart.totalTokens > budget ? "error" : "warning";
|
|
129
|
+
lines.push(`${label.padStart(USAGE_Y_AXIS_WIDTH - 2)} ${theme.fg("borderMuted", "│")}${theme.fg(color, "┄".repeat(plotWidth))}`);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
105
132
|
let plot = "";
|
|
106
133
|
for (const bucket of buckets) {
|
|
107
134
|
const scaled = bucket.total / maximum * USAGE_CHART_HEIGHT;
|
|
@@ -118,7 +145,7 @@ export function renderUsageHistogram(chart: UsageHistogram, width: number, theme
|
|
|
118
145
|
lines.push(`${label.padStart(USAGE_Y_AXIS_WIDTH - 2)} ${theme.fg("borderMuted", "│")}${plot}`);
|
|
119
146
|
}
|
|
120
147
|
lines.push(`${"0".padStart(USAGE_Y_AXIS_WIDTH - 2)} ${theme.fg("borderMuted", `└${"─".repeat(plotWidth)}`)}`);
|
|
121
|
-
lines.push(`${" ".repeat(USAGE_Y_AXIS_WIDTH)}${axisLabels(chart.start, chart.end, chart.
|
|
148
|
+
lines.push(`${" ".repeat(USAGE_Y_AXIS_WIDTH)}${axisLabels(chart.start, chart.end, chart.period, plotWidth)}`);
|
|
122
149
|
lines.push("");
|
|
123
150
|
for (let index = 0; index < chart.series.length; index += 1) {
|
|
124
151
|
const series = chart.series[index]!;
|
|
@@ -128,42 +155,48 @@ export function renderUsageHistogram(chart: UsageHistogram, width: number, theme
|
|
|
128
155
|
return lines.map((line) => visibleWidth(line) <= safeWidth ? line : truncateToWidth(line, safeWidth, "…"));
|
|
129
156
|
}
|
|
130
157
|
|
|
131
|
-
async function loadUsage(client: JittorPanelClient,
|
|
158
|
+
async function loadUsage(client: JittorPanelClient, period: UsagePeriod, now: number): Promise<UsageGraph> {
|
|
132
159
|
const rows = await client.call("metrics.query", {
|
|
133
160
|
source: "pi",
|
|
134
|
-
since:
|
|
161
|
+
since: usagePeriodStart(period, now),
|
|
135
162
|
until: now,
|
|
136
163
|
order: "desc",
|
|
137
164
|
limit: USAGE_TOKEN_QUERY_LIMIT,
|
|
138
165
|
}) as StoredMetricObservation[];
|
|
139
|
-
return
|
|
166
|
+
return buildUsageGraph(rows, { period, now, truncated: rows.length >= USAGE_TOKEN_QUERY_LIMIT });
|
|
140
167
|
}
|
|
141
168
|
|
|
142
|
-
export async function showUsagePanel(
|
|
143
|
-
|
|
169
|
+
export async function showUsagePanel(
|
|
170
|
+
ctx: ExtensionCommandContext,
|
|
171
|
+
client: JittorPanelClient,
|
|
172
|
+
budgets: Pick<UsageBudgetControl, "getUsageTokenBudget">,
|
|
173
|
+
now = Date.now(),
|
|
174
|
+
): Promise<void> {
|
|
175
|
+
let periodIndex = 0;
|
|
144
176
|
for (;;) {
|
|
145
|
-
const
|
|
146
|
-
const chart = await loadUsage(client,
|
|
177
|
+
const period = USAGE_PERIODS[periodIndex]!.id;
|
|
178
|
+
const chart = await loadUsage(client, period, now);
|
|
179
|
+
const tokenBudget = budgets.getUsageTokenBudget(period);
|
|
147
180
|
if (ctx.mode !== "tui") {
|
|
148
|
-
ctx.ui.notify(
|
|
181
|
+
ctx.ui.notify(renderUsageGraph(chart, 80, plainTheme(), tokenBudget).join("\n"), "info");
|
|
149
182
|
return;
|
|
150
183
|
}
|
|
151
184
|
const action = await ctx.ui.custom<UsageAction>((_tui, theme, _keybindings, done) => ({
|
|
152
185
|
invalidate() {},
|
|
153
186
|
render(width: number): string[] {
|
|
154
|
-
const lines =
|
|
155
|
-
const controls = theme.fg("dim", "←/→
|
|
187
|
+
const lines = renderUsageGraph(chart, width, theme, tokenBudget);
|
|
188
|
+
const controls = theme.fg("dim", "←/→ period · r refresh · Esc close");
|
|
156
189
|
return [...lines, "", truncateToWidth(controls, width, "…")];
|
|
157
190
|
},
|
|
158
191
|
handleInput(data: string): void {
|
|
159
192
|
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c") || data === "q") done("close");
|
|
160
|
-
else if (matchesKey(data, "left")) done("
|
|
161
|
-
else if (matchesKey(data, "right")) done("
|
|
193
|
+
else if (matchesKey(data, "left")) done("period-prev");
|
|
194
|
+
else if (matchesKey(data, "right")) done("period-next");
|
|
162
195
|
else if (data === "r") done("refresh");
|
|
163
196
|
},
|
|
164
197
|
}));
|
|
165
198
|
if (!action || action === "close") return;
|
|
166
|
-
if (action === "
|
|
167
|
-
if (action === "
|
|
199
|
+
if (action === "period-prev") periodIndex = (periodIndex - 1 + USAGE_PERIODS.length) % USAGE_PERIODS.length;
|
|
200
|
+
if (action === "period-next") periodIndex = (periodIndex + 1) % USAGE_PERIODS.length;
|
|
168
201
|
}
|
|
169
202
|
}
|
package/package.json
CHANGED
package/src/constants.ts
CHANGED
|
@@ -28,6 +28,7 @@ export const SYSTEMD_UNIT_NAME = "jittor.service";
|
|
|
28
28
|
export const USAGE_CHART_HEIGHT = 8;
|
|
29
29
|
export const USAGE_Y_AXIS_WIDTH = 7;
|
|
30
30
|
export const USAGE_TOKEN_QUERY_LIMIT = 10_000;
|
|
31
|
+
export const MAX_USAGE_BUCKETS = 120;
|
|
31
32
|
export const MAX_DYNAMIC_ROUTES = 100;
|
|
32
33
|
export const CODEX_ERROR_MESSAGE_LIMIT = 160;
|
|
33
34
|
export const CODEX_RETRY_AFTER_MAX_MS = 5 * MILLISECONDS_PER_MINUTE;
|
package/src/domain/usage.ts
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
|
+
import { MAX_USAGE_BUCKETS, MILLISECONDS_PER_DAY, MILLISECONDS_PER_HOUR } from "../constants.ts";
|
|
1
2
|
import type { StoredMetricObservation } from "./metric.ts";
|
|
2
3
|
|
|
3
|
-
export const
|
|
4
|
-
|
|
4
|
+
export const USAGE_PERIODS = [
|
|
5
|
+
{ id: "hourly", label: "Hourly", windowMs: MILLISECONDS_PER_HOUR, bucketCount: 12 },
|
|
6
|
+
{ id: "daily", label: "Daily", windowMs: MILLISECONDS_PER_DAY, bucketCount: 24 },
|
|
7
|
+
{ id: "weekly", label: "Weekly", windowMs: 7 * MILLISECONDS_PER_DAY, bucketCount: 28 },
|
|
8
|
+
{ id: "monthly", label: "Monthly", windowMs: 30 * MILLISECONDS_PER_DAY, bucketCount: 30 },
|
|
9
|
+
] as const;
|
|
10
|
+
export type UsagePeriod = typeof USAGE_PERIODS[number]["id"];
|
|
11
|
+
|
|
12
|
+
export function usagePeriod(period: UsagePeriod): typeof USAGE_PERIODS[number] {
|
|
13
|
+
return USAGE_PERIODS.find((candidate) => candidate.id === period)!;
|
|
14
|
+
}
|
|
5
15
|
|
|
6
16
|
export interface UsageSeries {
|
|
7
17
|
key: string;
|
|
@@ -24,31 +34,24 @@ export interface UsageBreakdown {
|
|
|
24
34
|
cacheWrite: number;
|
|
25
35
|
}
|
|
26
36
|
|
|
27
|
-
export interface
|
|
28
|
-
|
|
37
|
+
export interface UsageGraph {
|
|
38
|
+
period: UsagePeriod;
|
|
29
39
|
start: number;
|
|
30
40
|
end: number;
|
|
31
41
|
buckets: UsageBucket[];
|
|
32
42
|
series: UsageSeries[];
|
|
33
43
|
totalTokens: number;
|
|
34
44
|
breakdown: UsageBreakdown;
|
|
45
|
+
truncated: boolean;
|
|
35
46
|
}
|
|
36
47
|
|
|
37
|
-
export interface
|
|
38
|
-
|
|
48
|
+
export interface UsageGraphOptions {
|
|
49
|
+
period: UsagePeriod;
|
|
39
50
|
now: number;
|
|
40
51
|
bucketCount?: number;
|
|
52
|
+
truncated?: boolean;
|
|
41
53
|
}
|
|
42
54
|
|
|
43
|
-
const RANGE_MILLISECONDS: Record<UsageRange, number> = {
|
|
44
|
-
"24h": 24 * 60 * 60 * 1_000,
|
|
45
|
-
"7d": 7 * 24 * 60 * 60 * 1_000,
|
|
46
|
-
"30d": 30 * 24 * 60 * 60 * 1_000,
|
|
47
|
-
"90d": 90 * 24 * 60 * 60 * 1_000,
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
const DEFAULT_BUCKETS: Record<UsageRange, number> = { "24h": 24, "7d": 28, "30d": 30, "90d": 30 };
|
|
51
|
-
|
|
52
55
|
const BREAKDOWN_KEYS = {
|
|
53
56
|
"input-tokens": "input",
|
|
54
57
|
"output-tokens": "output",
|
|
@@ -56,8 +59,8 @@ const BREAKDOWN_KEYS = {
|
|
|
56
59
|
"cache-write-tokens": "cacheWrite",
|
|
57
60
|
} as const satisfies Record<string, keyof UsageBreakdown>;
|
|
58
61
|
|
|
59
|
-
export function
|
|
60
|
-
return Math.max(0, now -
|
|
62
|
+
export function usagePeriodStart(period: UsagePeriod, now: number): number {
|
|
63
|
+
return Math.max(0, now - usagePeriod(period).windowMs);
|
|
61
64
|
}
|
|
62
65
|
|
|
63
66
|
function identity(row: StoredMetricObservation): { key: string; provider: string; model: string } {
|
|
@@ -69,11 +72,11 @@ function identity(row: StoredMetricObservation): { key: string; provider: string
|
|
|
69
72
|
return { key: `${provider}/${model}`, provider, model };
|
|
70
73
|
}
|
|
71
74
|
|
|
72
|
-
export function
|
|
75
|
+
export function buildUsageGraph(rows: StoredMetricObservation[], options: UsageGraphOptions): UsageGraph {
|
|
73
76
|
const end = options.now;
|
|
74
|
-
const start =
|
|
75
|
-
const requestedBuckets = options.bucketCount ??
|
|
76
|
-
const bucketCount = Math.max(1, Math.min(
|
|
77
|
+
const start = usagePeriodStart(options.period, end);
|
|
78
|
+
const requestedBuckets = options.bucketCount ?? usagePeriod(options.period).bucketCount;
|
|
79
|
+
const bucketCount = Math.max(1, Math.min(MAX_USAGE_BUCKETS, Math.floor(requestedBuckets)));
|
|
77
80
|
const bucketSize = Math.max(1, (end - start) / bucketCount);
|
|
78
81
|
const buckets: UsageBucket[] = Array.from({ length: bucketCount }, (_, index) => ({
|
|
79
82
|
start: start + index * bucketSize,
|
|
@@ -101,12 +104,13 @@ export function buildUsageHistogram(rows: StoredMetricObservation[], options: Us
|
|
|
101
104
|
|
|
102
105
|
const series = [...identities.values()].sort((left, right) => right.total - left.total || left.key.localeCompare(right.key));
|
|
103
106
|
return {
|
|
104
|
-
|
|
107
|
+
period: options.period,
|
|
105
108
|
start,
|
|
106
109
|
end,
|
|
107
110
|
buckets,
|
|
108
111
|
series,
|
|
109
112
|
totalTokens: buckets.reduce((sum, bucket) => sum + bucket.total, 0),
|
|
110
113
|
breakdown,
|
|
114
|
+
truncated: options.truncated === true,
|
|
111
115
|
};
|
|
112
116
|
}
|