@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 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
- Run `/jittor usage` for a colored Unicode token histogram with X/Y axes, provider/model series, input/output/cache totals, refresh, and `24h`, `7d`, `30d`, or `90d` ranges. Left/Right changes range and `r` refreshes. Usage is persisted by the daemon from finalized Pi assistant messages.
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
 
@@ -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 PersistentExtensionControl extends EnforcementControl, CodexRecoveryControl {
18
- setCodexRecoveryEnabled(enabled: boolean): void;
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 { enforcementEnabled: true, footerEnabled: true, codexRecoveryEnabled: false };
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 { enforcementEnabled: true, footerEnabled: true, codexRecoveryEnabled: false };
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
  }
@@ -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
- buildUsageHistogram,
7
- USAGE_RANGES,
8
- usageRangeStart,
6
+ buildUsageGraph,
7
+ USAGE_PERIODS,
8
+ usagePeriod,
9
+ usagePeriodStart,
9
10
  type UsageBucket,
10
- type UsageHistogram,
11
- type UsageRange,
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 = "range-prev" | "range-next" | "refresh" | "close";
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: UsageHistogram, tokenHeight: number): number {
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 formatRangePoint(value: number, range: UsageRange): string {
65
+ function formatPeriodPoint(value: number, period: UsagePeriod): string {
64
66
  const date = new Date(value);
65
- if (range === "24h") return date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
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, range: UsageRange, width: number): string {
70
- const labels = [formatRangePoint(start, range), formatRangePoint(start + (end - start) / 2, range), formatRangePoint(end, range)];
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 renderUsageHistogram(chart: UsageHistogram, width: number, theme: UsageTheme): string[] {
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 buckets = mergeBuckets(chart.buckets, chartColumns);
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 maximum = Math.max(0, ...buckets.map((bucket) => bucket.total));
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(`Tokens over time · ${chart.range}`), safeWidth, ""),
94
- truncateToWidth(`${compact(chart.totalTokens)} tokens · input ${compact(chart.breakdown.input)} · output ${compact(chart.breakdown.output)} · cache ${compact(chart.breakdown.cacheRead + chart.breakdown.cacheWrite)}`, safeWidth, "…"),
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 range."));
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 label = row === 0 ? compact(maximum) : row === Math.floor(USAGE_CHART_HEIGHT / 2) ? compact(maximum / 2) : "";
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.range, plotWidth)}`);
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, range: UsageRange, now: number): Promise<UsageHistogram> {
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: usageRangeStart(range, now),
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 buildUsageHistogram(rows, { range, now });
166
+ return buildUsageGraph(rows, { period, now, truncated: rows.length >= USAGE_TOKEN_QUERY_LIMIT });
140
167
  }
141
168
 
142
- export async function showUsagePanel(ctx: ExtensionCommandContext, client: JittorPanelClient, now = Date.now()): Promise<void> {
143
- let rangeIndex = 0;
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 range = USAGE_RANGES[rangeIndex]!;
146
- const chart = await loadUsage(client, range, now);
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(renderUsageHistogram(chart, 80, plainTheme()).join("\n"), "info");
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 = renderUsageHistogram(chart, width, theme);
155
- const controls = theme.fg("dim", "←/→ range · r refresh · Esc close");
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("range-prev");
161
- else if (matchesKey(data, "right")) done("range-next");
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 === "range-prev") rangeIndex = (rangeIndex - 1 + USAGE_RANGES.length) % USAGE_RANGES.length;
167
- if (action === "range-next") rangeIndex = (rangeIndex + 1) % USAGE_RANGES.length;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/jittor",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "Just-in-Time Token Optimizing Router for Pi",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package", "llm-router", "token-budget"],
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;
@@ -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 USAGE_RANGES = ["24h", "7d", "30d", "90d"] as const;
4
- export type UsageRange = typeof USAGE_RANGES[number];
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 UsageHistogram {
28
- range: UsageRange;
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 UsageHistogramOptions {
38
- range: UsageRange;
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 usageRangeStart(range: UsageRange, now: number): number {
60
- return Math.max(0, now - RANGE_MILLISECONDS[range]);
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 buildUsageHistogram(rows: StoredMetricObservation[], options: UsageHistogramOptions): UsageHistogram {
75
+ export function buildUsageGraph(rows: StoredMetricObservation[], options: UsageGraphOptions): UsageGraph {
73
76
  const end = options.now;
74
- const start = usageRangeStart(options.range, end);
75
- const requestedBuckets = options.bucketCount ?? DEFAULT_BUCKETS[options.range];
76
- const bucketCount = Math.max(1, Math.min(120, Math.floor(requestedBuckets)));
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
- range: options.range,
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
  }