@danypops/jittor 0.2.0 → 0.3.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
@@ -41,17 +41,22 @@ Blocking always has a daemon-independent escape hatch. `/jittor off` immediately
41
41
 
42
42
  ### Opt-in Codex settled-turn recovery
43
43
 
44
- Transient Codex recovery is securely off by default. Opt in explicitly through `$XDG_CONFIG_HOME/jittor/extension.json` (or `~/.config/jittor/extension.json`):
44
+ Transient Codex recovery is securely off by default and controlled through the existing Jittor command surface:
45
45
 
46
- ```json
47
- {
48
- "codexRecoveryEnabled": true
49
- }
46
+ ```text
47
+ /jittor recovery status
48
+ /jittor recovery on
49
+ /jittor recovery off
50
+ /jittor recovery cancel
50
51
  ```
51
52
 
53
+ The on/off choice persists privately in `$XDG_CONFIG_HOME/jittor/extension.json` (or `~/.config/jittor/extension.json`). Status reports only enabled state, cooldown, bounded attempt/window counters, and the normalized failure class. `cancel` clears the current cooldown and attempt window without changing the persisted on/off choice.
54
+
52
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.
53
56
 
54
- 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
+ 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.
58
+
59
+ 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`).
55
60
 
56
61
  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.
57
62
 
@@ -7,15 +7,18 @@ import {
7
7
  CODEX_RECOVERY_MAX_DELAY_MS,
8
8
  FOOTER_COMPACTION_RENDER_INTERVAL_MS,
9
9
  MAX_DYNAMIC_ROUTES,
10
+ MILLISECONDS_PER_MINUTE,
11
+ MILLISECONDS_PER_SECOND,
10
12
  } from "../../src/constants.ts";
11
- import { CodexRecoveryPolicy, classifyCodexFailure, type CodexFailureMetadata } from "../../src/domain/codex-recovery.ts";
13
+ import { CodexRecoveryPolicy, classifyCodexFailure, type CodexFailureKind, type CodexFailureMetadata } from "../../src/domain/codex-recovery.ts";
12
14
  import type { MetricObservation, StoredMetricObservation } from "../../src/domain/metric.ts";
15
+ import { USAGE_PERIODS, type UsagePeriod } from "../../src/domain/usage.ts";
13
16
  import type { PolicyDecision, Route } from "../../src/policy.ts";
14
17
  import type { RouterStatus } from "../../src/ports/router-controller.ts";
15
18
  import { parseCodexRateLimitHeaders } from "../../src/providers/codex.ts";
16
19
  import { installIntegratedFooter, type IntegratedFooterState } from "./footer.ts";
17
20
  import { callJittor } from "./service-client.ts";
18
- import { persistentEnforcementControl, type CodexRecoveryControl, type EnforcementControl } from "./settings.ts";
21
+ import { persistentEnforcementControl, type CodexRecoveryControl, type EnforcementControl, type UsageBudgetControl } from "./settings.ts";
19
22
  import { buildFooterBudget, formatFooterStatus, showJittorPanel } from "./tui.ts";
20
23
  import { showUsagePanel } from "./usage.ts";
21
24
 
@@ -46,11 +49,25 @@ const SYSTEM_RECOVERY_RUNTIME: CodexRecoveryRuntime = {
46
49
  clearTimeout(handle) { clearTimeout(handle as ReturnType<typeof setTimeout>); },
47
50
  };
48
51
 
52
+ function usageBudgetControl(enforcement: EnforcementControl): UsageBudgetControl {
53
+ const candidate = enforcement as EnforcementControl & Partial<UsageBudgetControl>;
54
+ return typeof candidate.getUsageTokenBudget === "function" && typeof candidate.setUsageTokenBudget === "function"
55
+ ? {
56
+ getUsageTokenBudget: (period) => candidate.getUsageTokenBudget!(period),
57
+ setUsageTokenBudget: (period, tokens) => candidate.setUsageTokenBudget!(period, tokens),
58
+ }
59
+ : { getUsageTokenBudget: () => undefined, setUsageTokenBudget() {} };
60
+ }
61
+
49
62
  function recoveryControl(enforcement: EnforcementControl): CodexRecoveryControl {
50
63
  const candidate = enforcement as EnforcementControl & Partial<CodexRecoveryControl>;
51
- return typeof candidate.isCodexRecoveryEnabled === "function"
52
- ? { isCodexRecoveryEnabled: () => candidate.isCodexRecoveryEnabled!() }
53
- : { isCodexRecoveryEnabled: () => false };
64
+ const set = (candidate as Partial<CodexRecoveryControl>).setCodexRecoveryEnabled;
65
+ return typeof candidate.isCodexRecoveryEnabled === "function" && typeof set === "function"
66
+ ? {
67
+ isCodexRecoveryEnabled: () => candidate.isCodexRecoveryEnabled!(),
68
+ setCodexRecoveryEnabled: (enabled) => set.call(candidate, enabled),
69
+ }
70
+ : { isCodexRecoveryEnabled: () => false, setCodexRecoveryEnabled() {} };
54
71
  }
55
72
 
56
73
  function header(headers: Record<string, string>, name: string): string | undefined {
@@ -208,6 +225,7 @@ export function registerJittorExtension(
208
225
  recoveryRuntime: CodexRecoveryRuntime = SYSTEM_RECOVERY_RUNTIME,
209
226
  ): void {
210
227
  const footerState: IntegratedFooterState = { providerBudget: null };
228
+ const usageBudgets = usageBudgetControl(enforcement);
211
229
  const recoveryPolicy = new CodexRecoveryPolicy({
212
230
  baseDelayMs: CODEX_RECOVERY_BASE_DELAY_MS,
213
231
  maxDelayMs: CODEX_RECOVERY_MAX_DELAY_MS,
@@ -216,12 +234,33 @@ export function registerJittorExtension(
216
234
  jitterRatio: CODEX_RECOVERY_JITTER_RATIO,
217
235
  }, recoveryRuntime.random);
218
236
  let recoveryTimer: unknown;
237
+ let recoveryCooldown: { until: number; attempt: number; failureKind: CodexFailureKind } | undefined;
219
238
  let lastCodexResponse: CodexFailureMetadata = {};
220
239
  const cancelRecovery = (resetPolicy: boolean): void => {
221
240
  if (recoveryTimer !== undefined) recoveryRuntime.clearTimeout(recoveryTimer);
222
241
  recoveryTimer = undefined;
242
+ recoveryCooldown = undefined;
223
243
  if (resetPolicy) recoveryPolicy.cancel();
224
244
  };
245
+ const recoveryStatusText = (): string => {
246
+ const now = recoveryRuntime.now();
247
+ const state = recoveryPolicy.state(now);
248
+ const enabled = codexRecovery.isCodexRecoveryEnabled();
249
+ const attempt = recoveryCooldown?.attempt ?? (state.pending ? state.attempts + 1 : state.attempts);
250
+ const phase = recoveryCooldown
251
+ ? `cooldown ${Math.ceil(Math.max(0, recoveryCooldown.until - now) / MILLISECONDS_PER_SECOND)}s`
252
+ : state.pending ? "pending"
253
+ : state.attempts >= CODEX_RECOVERY_MAX_ATTEMPTS ? "exhausted"
254
+ : state.attempts > 0 ? "waiting" : "idle";
255
+ const failureKind = recoveryCooldown?.failureKind ?? state.lastFailureKind;
256
+ return [
257
+ `Codex recovery: ${enabled ? "on" : "off"}`,
258
+ phase,
259
+ `attempt ${attempt}/${CODEX_RECOVERY_MAX_ATTEMPTS}`,
260
+ `window ${CODEX_RECOVERY_ATTEMPT_WINDOW_MS / MILLISECONDS_PER_MINUTE}m`,
261
+ ...(failureKind ? [failureKind] : []),
262
+ ].join(" · ");
263
+ };
225
264
  const scheduleCodexRecovery = (ctx: ExtensionContext): void => {
226
265
  if (!codexRecovery.isCodexRecoveryEnabled() || recoveryTimer !== undefined || !ctx.isIdle() || ctx.hasPendingMessages()) return;
227
266
  const plan = recoveryPolicy.plan(recoveryRuntime.now());
@@ -231,8 +270,10 @@ export function registerJittorExtension(
231
270
  return;
232
271
  }
233
272
  if (plan.action !== "schedule") return;
273
+ recoveryCooldown = { until: recoveryRuntime.now() + plan.delayMs, attempt: plan.attempt, failureKind: plan.failureKind };
234
274
  recoveryTimer = recoveryRuntime.setTimeout(async () => {
235
275
  recoveryTimer = undefined;
276
+ recoveryCooldown = undefined;
236
277
  if (!ctx.isIdle() || ctx.hasPendingMessages()) return;
237
278
  const attempt = recoveryPolicy.recordAttempt(recoveryRuntime.now());
238
279
  if (!attempt) return;
@@ -296,6 +337,26 @@ export function registerJittorExtension(
296
337
  description: "Inspect or control Jittor routing, budgets, and usage",
297
338
  handler: async (args, ctx) => {
298
339
  const action = args.trim().toLowerCase();
340
+ if (action === "recovery" || action === "recovery status") {
341
+ ctx.ui.notify(recoveryStatusText(), "info");
342
+ return;
343
+ }
344
+ if (action === "recovery on" || action === "recovery enable") {
345
+ codexRecovery.setCodexRecoveryEnabled(true);
346
+ ctx.ui.notify("Jittor Codex recovery enabled; bounded retries begin only after transient failures fully settle.", "info");
347
+ return;
348
+ }
349
+ if (action === "recovery off" || action === "recovery disable") {
350
+ cancelRecovery(true);
351
+ codexRecovery.setCodexRecoveryEnabled(false);
352
+ ctx.ui.notify("Jittor Codex recovery disabled and pending recovery cleared.", "info");
353
+ return;
354
+ }
355
+ if (action === "recovery cancel") {
356
+ cancelRecovery(true);
357
+ ctx.ui.notify(`Jittor Codex recovery cooldown and attempt window cleared; recovery remains ${codexRecovery.isCodexRecoveryEnabled() ? "on" : "off"}.`, "info");
358
+ return;
359
+ }
299
360
  if (action === "off" || action === "disable") { disable(ctx); return; }
300
361
  if (action === "on" || action === "enable") { await enable(ctx); return; }
301
362
  if (action === "footer off" || action === "footer disable") {
@@ -311,8 +372,34 @@ export function registerJittorExtension(
311
372
  ctx.ui.notify("Jittor informational footer enabled; routing enforcement is unchanged.", "info");
312
373
  return;
313
374
  }
375
+ if (action === "usage budget" || action.startsWith("usage budget ")) {
376
+ const [, , periodText, valueText] = action.split(/\s+/);
377
+ const period = USAGE_PERIODS.some((candidate) => candidate.id === periodText) ? periodText as UsagePeriod : undefined;
378
+ if (!period) {
379
+ const values = USAGE_PERIODS.map(({ id, label }) => `${label}: ${usageBudgets.getUsageTokenBudget(id)?.toLocaleString() ?? "not configured"}`).join(" · ");
380
+ ctx.ui.notify(`Token budgets · ${values}`, "info");
381
+ return;
382
+ }
383
+ if (valueText === undefined) {
384
+ ctx.ui.notify(`${USAGE_PERIODS.find((candidate) => candidate.id === period)!.label} token budget: ${usageBudgets.getUsageTokenBudget(period)?.toLocaleString() ?? "not configured"}`, "info");
385
+ return;
386
+ }
387
+ if (valueText === "off" || valueText === "clear") {
388
+ usageBudgets.setUsageTokenBudget(period, undefined);
389
+ ctx.ui.notify(`${USAGE_PERIODS.find((candidate) => candidate.id === period)!.label} token budget cleared.`, "info");
390
+ return;
391
+ }
392
+ const tokens = Number(valueText.replaceAll(",", ""));
393
+ if (!Number.isFinite(tokens) || tokens <= 0) {
394
+ ctx.ui.notify("Usage: /jittor usage budget <hourly|daily|weekly|monthly> <positive-tokens|off>", "warning");
395
+ return;
396
+ }
397
+ usageBudgets.setUsageTokenBudget(period, tokens);
398
+ ctx.ui.notify(`${USAGE_PERIODS.find((candidate) => candidate.id === period)!.label} token budget set to ${tokens.toLocaleString()} tokens.`, "info");
399
+ return;
400
+ }
314
401
  if (action === "usage") {
315
- await showUsagePanel(ctx, client);
402
+ await showUsagePanel(ctx, client, usageBudgets);
316
403
  return;
317
404
  }
318
405
  if (!enforcement.isEnabled()) {
@@ -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;
@@ -11,16 +12,34 @@ export interface EnforcementControl {
11
12
 
12
13
  export interface CodexRecoveryControl {
13
14
  isCodexRecoveryEnabled(): boolean;
15
+ setCodexRecoveryEnabled(enabled: boolean): void;
14
16
  }
15
17
 
16
- export interface PersistentExtensionControl extends EnforcementControl, CodexRecoveryControl {
17
- setCodexRecoveryEnabled(enabled: boolean): void;
18
+ export interface UsageBudgetControl {
19
+ getUsageTokenBudget(period: UsagePeriod): number | undefined;
20
+ setUsageTokenBudget(period: UsagePeriod, tokens: number | undefined): void;
18
21
  }
19
22
 
23
+ export interface PersistentExtensionControl extends EnforcementControl, CodexRecoveryControl, UsageBudgetControl {}
24
+
20
25
  interface ExtensionSettings {
21
26
  enforcementEnabled: boolean;
22
27
  footerEnabled: boolean;
23
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>>;
24
43
  }
25
44
 
26
45
  function settingsPath(env: Record<string, string | undefined> = process.env): string {
@@ -31,15 +50,16 @@ function settingsPath(env: Record<string, string | undefined> = process.env): st
31
50
  function loadSettings(path: string): ExtensionSettings {
32
51
  try {
33
52
  const value = JSON.parse(readFileSync(path, "utf8")) as unknown;
34
- 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();
35
54
  const record = value as Record<string, unknown>;
36
55
  return {
37
56
  enforcementEnabled: record["enforcementEnabled"] !== false,
38
57
  footerEnabled: record["footerEnabled"] !== false,
39
58
  codexRecoveryEnabled: record["codexRecoveryEnabled"] === true,
59
+ usageTokenBudgets: parseUsageTokenBudgets(record["usageTokenBudgets"]),
40
60
  };
41
61
  } catch {
42
- return { enforcementEnabled: true, footerEnabled: true, codexRecoveryEnabled: false };
62
+ return defaultSettings();
43
63
  }
44
64
  }
45
65
 
@@ -70,5 +90,14 @@ export function persistentEnforcementControl(env: Record<string, string | undefi
70
90
  settings.codexRecoveryEnabled = value;
71
91
  persistSettings(path, settings);
72
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
+ },
73
102
  };
74
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.0",
3
+ "version": "0.3.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;
@@ -50,6 +50,7 @@ export class CodexRecoveryPolicy {
50
50
  private pendingFailure: CodexFailure | undefined;
51
51
  private attempts = 0;
52
52
  private windowStartedAt: number | undefined;
53
+ private lastFailureKind: CodexFailureKind | undefined;
53
54
 
54
55
  constructor(
55
56
  private readonly options: CodexRecoveryOptions,
@@ -65,6 +66,7 @@ export class CodexRecoveryPolicy {
65
66
  observeFailure(failure: CodexFailure, now: number): void {
66
67
  this.normalizeWindow(now);
67
68
  this.pendingFailure = failure.transient ? failure : undefined;
69
+ this.lastFailureKind = failure.transient ? failure.kind : undefined;
68
70
  }
69
71
 
70
72
  observeSuccess(): void {
@@ -75,6 +77,7 @@ export class CodexRecoveryPolicy {
75
77
  this.pendingFailure = undefined;
76
78
  this.attempts = 0;
77
79
  this.windowStartedAt = undefined;
80
+ this.lastFailureKind = undefined;
78
81
  }
79
82
 
80
83
  abandonFailure(): void {
@@ -112,14 +115,21 @@ export class CodexRecoveryPolicy {
112
115
  return attempt;
113
116
  }
114
117
 
115
- state(): { attempts: number; pending: boolean } {
116
- return { attempts: this.attempts, pending: this.pendingFailure !== undefined };
118
+ state(now?: number): { attempts: number; pending: boolean; lastFailureKind?: CodexFailureKind; windowStartedAt?: number } {
119
+ if (now !== undefined) this.normalizeWindow(now);
120
+ return {
121
+ attempts: this.attempts,
122
+ pending: this.pendingFailure !== undefined,
123
+ ...(this.lastFailureKind ? { lastFailureKind: this.lastFailureKind } : {}),
124
+ ...(this.windowStartedAt !== undefined ? { windowStartedAt: this.windowStartedAt } : {}),
125
+ };
117
126
  }
118
127
 
119
128
  private normalizeWindow(now: number): void {
120
129
  if (this.windowStartedAt !== undefined && now - this.windowStartedAt >= this.options.attemptWindowMs) {
121
130
  this.attempts = 0;
122
131
  this.windowStartedAt = undefined;
132
+ if (!this.pendingFailure) this.lastFailureKind = undefined;
123
133
  }
124
134
  }
125
135
  }
@@ -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
  }