gentle-pi 3.2.0 → 3.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/assets/orchestrator-delegation.md +13 -8
- package/assets/orchestrator.md +2 -2
- package/docs/gentle-shell.md +40 -17
- package/docs/readme-reference.md +41 -7
- package/docs/review-integration.md +25 -11
- package/extensions/gentle-agents.ts +85 -17
- package/extensions/gentle-ai.ts +179 -12
- package/extensions/gentle-shell.ts +408 -38
- package/extensions/gentle-todo.ts +19 -1
- package/lib/agents-view.ts +41 -14
- package/lib/agents-widget.ts +84 -13
- package/lib/command-palette-catalog.ts +1 -0
- package/lib/double-esc-cancel-policy.ts +138 -0
- package/lib/inprocess-reviewer.ts +260 -0
- package/lib/model-routing-authority.ts +1 -1
- package/lib/native-review-cli.ts +23 -0
- package/lib/odd-runtime-delegation-gate.ts +88 -0
- package/lib/review-host-relay.ts +262 -94
- package/lib/review-integration-v2.ts +110 -26
- package/lib/shell-bar.ts +158 -29
- package/lib/shell-card.ts +19 -9
- package/lib/shell-changes-view.ts +43 -5
- package/lib/shell-changes.ts +92 -5
- package/lib/shell-hover.ts +39 -0
- package/lib/shell-prompt.ts +10 -1
- package/lib/shell-sidebar-layout.ts +111 -15
- package/lib/shell-sidebar.ts +16 -0
- package/lib/shell-todo.ts +7 -1
- package/lib/shell-usage-view.ts +98 -10
- package/lib/shell-usage.ts +226 -10
- package/package.json +2 -1
- package/runtime/native-review-cli.mjs +23 -0
- package/runtime/review-integration-v2.mjs +110 -26
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
- package/scripts/mirror-odd-routing.mjs +242 -0
- package/scripts/verify-package-files.mjs +3 -3
- package/tests/agents-grouping.test.ts +75 -18
- package/tests/agents-view.test.ts +28 -18
- package/tests/agents-widget.test.ts +100 -12
- package/tests/command-palette.test.ts +1 -0
- package/tests/devbinary/pi-host-relay.devtest.ts +176 -138
- package/tests/double-esc-cancel-policy.test.ts +194 -0
- package/tests/gentle-agents.test.ts +528 -5
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai.test.ts +69 -5
- package/tests/gentle-shell.test.ts +903 -25
- package/tests/gentle-todo.test.ts +17 -4
- package/tests/inprocess-reviewer.test.ts +368 -0
- package/tests/maintainer/provider-relay.maintest.ts +101 -143
- package/tests/native-review-capability-contract.test.ts +32 -1
- package/tests/odd-routing-canonical-ratchet.test.ts +293 -0
- package/tests/odd-routing-contract.test.ts +57 -0
- package/tests/odd-runtime-delegation-gate.test.ts +212 -0
- package/tests/orchestrator-rdd-ownership.test.ts +3 -3
- package/tests/package-manifest.test.ts +6 -6
- package/tests/review-controller-native-routing.test.ts +60 -1
- package/tests/review-host-relay-routing.test.ts +77 -0
- package/tests/review-host-relay.test.ts +285 -239
- package/tests/review-integration-v2-forward.test.ts +61 -0
- package/tests/review-integration-v2.test.ts +116 -1
- package/tests/review-relay-transport-agent.test.ts +83 -0
- package/tests/runtime-harness.mjs +11 -0
- package/tests/session-changes-shell.test.ts +27 -0
- package/tests/session-worktree-registry.test.ts +41 -0
- package/tests/shell-bar.test.ts +224 -6
- package/tests/shell-card.test.ts +5 -3
- package/tests/shell-changes-view.test.ts +47 -0
- package/tests/shell-changes.test.ts +177 -0
- package/tests/shell-hover.test.ts +19 -0
- package/tests/shell-prompt.test.ts +20 -0
- package/tests/shell-sidebar-fullscreen.test.ts +59 -0
- package/tests/shell-sidebar-layout.test.ts +243 -5
- package/tests/shell-sidebar.test.ts +25 -1
- package/tests/shell-todo.test.ts +36 -0
- package/tests/shell-usage-view.test.ts +123 -3
- package/tests/shell-usage.test.ts +254 -6
- package/lib/opaque-pi-reviewer-adapter.ts +0 -284
- package/tests/opaque-pi-reviewer-adapter.test.ts +0 -266
package/lib/shell-usage-view.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
1
|
+
import { Key, matchesKey, truncateToWidth, visibleWidth, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
|
|
2
2
|
import { renderUsagePanel, type ActiveProvider, type UsageStore, type UsageTheme } from "./shell-usage.ts";
|
|
3
|
+
import { paintHoverable } from "./shell-hover.ts";
|
|
3
4
|
|
|
4
5
|
// Gentle Shell subscriptions overlay: a framed panel over the usage store.
|
|
5
6
|
// It reads the store on every render, so a refresh only needs to record.
|
|
@@ -33,10 +34,32 @@ function fit(text: string, width: number): string {
|
|
|
33
34
|
return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
// Column offset of the footer's hint text within the rendered line: the
|
|
38
|
+
// frame draws "│ " before the fitted content starts.
|
|
39
|
+
const HINT_CONTENT_OFFSET = 2;
|
|
40
|
+
const HINT_GAP = " ";
|
|
41
|
+
|
|
42
|
+
type HintAction = "refresh" | "close";
|
|
43
|
+
|
|
44
|
+
interface HintSpan {
|
|
45
|
+
start: number;
|
|
46
|
+
end: number;
|
|
47
|
+
action: HintAction;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface PointerLayout {
|
|
51
|
+
width: number;
|
|
52
|
+
height: number;
|
|
53
|
+
row: number;
|
|
54
|
+
spans: HintSpan[];
|
|
55
|
+
}
|
|
56
|
+
|
|
36
57
|
export class UsageView {
|
|
37
58
|
private readonly store: UsageStore;
|
|
38
59
|
private readonly deps: UsageViewDeps;
|
|
39
60
|
private refreshing = false;
|
|
61
|
+
private pointer: PointerLayout | undefined;
|
|
62
|
+
private hoveredHint: HintAction | undefined;
|
|
40
63
|
|
|
41
64
|
constructor(store: UsageStore, deps: UsageViewDeps) {
|
|
42
65
|
this.store = store;
|
|
@@ -48,13 +71,25 @@ export class UsageView {
|
|
|
48
71
|
this.deps.onClose();
|
|
49
72
|
return;
|
|
50
73
|
}
|
|
51
|
-
if (data === "r"
|
|
52
|
-
|
|
74
|
+
if (data === "r") this.refresh();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// A failing usage fetch is the store's problem to report (its rows already
|
|
78
|
+
// carry the last error); the panel only clears its "refreshing" state. The
|
|
79
|
+
// rejection must never leave this method: an unhandled rejection is fatal
|
|
80
|
+
// to the whole shell on current Node.
|
|
81
|
+
private refresh(): void {
|
|
82
|
+
if (this.refreshing) return;
|
|
83
|
+
this.refreshing = true;
|
|
84
|
+
this.deps.requestRender();
|
|
85
|
+
const settle = () => {
|
|
86
|
+
this.refreshing = false;
|
|
53
87
|
this.deps.requestRender();
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
88
|
+
};
|
|
89
|
+
try {
|
|
90
|
+
this.deps.onRefresh().then(settle, settle);
|
|
91
|
+
} catch {
|
|
92
|
+
settle();
|
|
58
93
|
}
|
|
59
94
|
}
|
|
60
95
|
|
|
@@ -66,11 +101,64 @@ export class UsageView {
|
|
|
66
101
|
const body = renderUsagePanel(this.store.all(), theme, inner - 2, this.deps.now(), this.deps.active()).map(
|
|
67
102
|
(line) => `${theme.fg(FRAME_ROLE, "│")} ${fit(line, inner - 2)} ${theme.fg(FRAME_ROLE, "│")}`,
|
|
68
103
|
);
|
|
69
|
-
const
|
|
104
|
+
const hints = KEYS.map(([key, label]) => ({ key, label, text: `${key} ${label}`, action: (key === "r" ? "refresh" : "close") as HintAction }));
|
|
105
|
+
// The hovered hint paints entirely in the shared hover role (key and
|
|
106
|
+
// label together, one color) instead of its ordinary two-role split --
|
|
107
|
+
// the same treatment every other clickable surface uses.
|
|
108
|
+
const keys = hints
|
|
109
|
+
.map(({ key, label, action }) =>
|
|
110
|
+
this.hoveredHint === action ? paintHoverable(theme, `${key} ${label}`, true) : `${theme.fg(KEY_ROLE, key)} ${theme.fg(KEY_TEXT_ROLE, label)}`,
|
|
111
|
+
)
|
|
112
|
+
.join(HINT_GAP);
|
|
70
113
|
const keysLine = `${theme.fg(FRAME_ROLE, "│")} ${fit(keys, inner - 2)} ${theme.fg(FRAME_ROLE, "│")}`;
|
|
71
114
|
const bottom = theme.fg(FRAME_ROLE, `╰${rule(inner)}╯`);
|
|
72
|
-
|
|
115
|
+
const lines = [top, ...body, keysLine, bottom];
|
|
116
|
+
this.pointer = this.hintLayout(width, lines.length, body.length + 1, hints, inner - 2);
|
|
117
|
+
return lines;
|
|
73
118
|
}
|
|
74
119
|
|
|
75
|
-
|
|
120
|
+
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
121
|
+
if (event.type === "move" && event.button === "none") {
|
|
122
|
+
const layout = this.pointer;
|
|
123
|
+
const action = layout && event.width === layout.width && event.height === layout.height && event.y === layout.row
|
|
124
|
+
? layout.spans.find((candidate) => event.x >= candidate.start && event.x < candidate.end)?.action
|
|
125
|
+
: undefined;
|
|
126
|
+
if (action === this.hoveredHint) return action ? { handled: true } : undefined;
|
|
127
|
+
this.hoveredHint = action;
|
|
128
|
+
return { handled: true, render: true };
|
|
129
|
+
}
|
|
130
|
+
if (event.type !== "click" || event.button !== "left") return undefined;
|
|
131
|
+
const layout = this.pointer;
|
|
132
|
+
if (!layout || event.width !== layout.width || event.height !== layout.height || event.y !== layout.row) return undefined;
|
|
133
|
+
const span = layout.spans.find((candidate) => event.x >= candidate.start && event.x < candidate.end);
|
|
134
|
+
if (!span) return undefined;
|
|
135
|
+
if (span.action === "close") {
|
|
136
|
+
this.deps.onClose();
|
|
137
|
+
return { handled: true, render: true };
|
|
138
|
+
}
|
|
139
|
+
this.refresh();
|
|
140
|
+
return { handled: true, render: true };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
invalidate(): void {
|
|
144
|
+
this.pointer = undefined;
|
|
145
|
+
this.hoveredHint = undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Spans are only registered when the hints text fits without truncation:
|
|
149
|
+
// past that point `fit` clips it with an ellipsis and per-hint columns no
|
|
150
|
+
// longer line up with the plain "key label" text used here.
|
|
151
|
+
private hintLayout(width: number, height: number, row: number, hints: Array<{ text: string; action: HintAction }>, contentWidth: number): PointerLayout | undefined {
|
|
152
|
+
const plainWidth = hints.reduce((total, hint) => total + hint.text.length, 0) + HINT_GAP.length * Math.max(0, hints.length - 1);
|
|
153
|
+
if (plainWidth > contentWidth) return undefined;
|
|
154
|
+
const spans: HintSpan[] = [];
|
|
155
|
+
let cursor = HINT_CONTENT_OFFSET;
|
|
156
|
+
for (const hint of hints) {
|
|
157
|
+
const start = cursor;
|
|
158
|
+
const end = start + hint.text.length;
|
|
159
|
+
spans.push({ start, end, action: hint.action });
|
|
160
|
+
cursor = end + HINT_GAP.length;
|
|
161
|
+
}
|
|
162
|
+
return { width, height, row, spans };
|
|
163
|
+
}
|
|
76
164
|
}
|
package/lib/shell-usage.ts
CHANGED
|
@@ -11,6 +11,11 @@ export interface UsageWindow {
|
|
|
11
11
|
usedPercent: number;
|
|
12
12
|
windowSeconds: number;
|
|
13
13
|
resetAt: number | null;
|
|
14
|
+
// Raw allowance numbers, kept only by providers that report them (NaN).
|
|
15
|
+
// Aggregates are weighted by budget, so averaging percentages is never
|
|
16
|
+
// needed; nothing renders these fields directly.
|
|
17
|
+
used?: number;
|
|
18
|
+
budget?: number;
|
|
14
19
|
}
|
|
15
20
|
|
|
16
21
|
export interface UsageLimit {
|
|
@@ -54,8 +59,27 @@ interface RawCodexUsage {
|
|
|
54
59
|
additional_rate_limits?: RawAdditionalLimit[] | null;
|
|
55
60
|
}
|
|
56
61
|
|
|
62
|
+
interface RawNanModel {
|
|
63
|
+
model?: unknown;
|
|
64
|
+
cap?: unknown;
|
|
65
|
+
fullCap?: unknown;
|
|
66
|
+
tokensUsed?: unknown;
|
|
67
|
+
periodEnd?: unknown;
|
|
68
|
+
windowHours?: unknown;
|
|
69
|
+
windowTokens?: unknown;
|
|
70
|
+
fullWindowTokens?: unknown;
|
|
71
|
+
windowTokensUsed?: unknown;
|
|
72
|
+
windowResetsAt?: unknown;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface RawNanQuota {
|
|
76
|
+
models?: unknown;
|
|
77
|
+
periodEnd?: unknown;
|
|
78
|
+
}
|
|
79
|
+
|
|
57
80
|
export const CODEX_PROVIDER = "openai-codex";
|
|
58
81
|
export const ANTHROPIC_PROVIDER = "anthropic";
|
|
82
|
+
export const NAN_PROVIDER = "nan";
|
|
59
83
|
const ANTHROPIC_MAIN_LIMIT = "claude";
|
|
60
84
|
const ANTHROPIC_PREFIX = "anthropic-ratelimit-unified-";
|
|
61
85
|
const ANTHROPIC_WINDOWS: ReadonlyArray<[key: string, seconds: number]> = [
|
|
@@ -63,6 +87,17 @@ const ANTHROPIC_WINDOWS: ReadonlyArray<[key: string, seconds: number]> = [
|
|
|
63
87
|
["7d", 604_800],
|
|
64
88
|
];
|
|
65
89
|
export const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
|
|
90
|
+
// The NaN Cloud dashboard backend; not part of NaN's published OpenAPI, so the
|
|
91
|
+
// fetch that uses it is fixed-origin, redirect-refusing, and schema-validated.
|
|
92
|
+
export const NAN_QUOTA_URL = "https://cloud-api.nan.builders/api/usage/quota";
|
|
93
|
+
// The model's own allowance for the billing period carries no label: the model
|
|
94
|
+
// id names it in the bar, and the reset text says what the window is in the
|
|
95
|
+
// panel. Only a sub-window on top of it (a rolling `4h`) needs a name.
|
|
96
|
+
const NAN_PERIOD_LABEL = "";
|
|
97
|
+
// The dashboard's own published fallbacks for a model that reports rolling
|
|
98
|
+
// numbers without naming its budget.
|
|
99
|
+
const NAN_DEFAULT_WINDOW_TOKENS = 400_000_000;
|
|
100
|
+
const NAN_DEFAULT_WINDOW_HOURS = 4;
|
|
66
101
|
const CODEX_MAIN_LIMIT = "codex";
|
|
67
102
|
const CODEX_ACCOUNT_CLAIM = "https://api.openai.com/auth";
|
|
68
103
|
const HEADER_PREFIX = "x-codex-";
|
|
@@ -81,9 +116,10 @@ const ROLE = {
|
|
|
81
116
|
SEPARATOR: "muted",
|
|
82
117
|
} as const;
|
|
83
118
|
export const USAGE_EMPTY_MESSAGE = "No subscription usage yet. Usage arrives with the next response, or press r to fetch it.";
|
|
84
|
-
export const SUPPORTED_USAGE_PROVIDERS: readonly string[] = [CODEX_PROVIDER, ANTHROPIC_PROVIDER];
|
|
119
|
+
export const SUPPORTED_USAGE_PROVIDERS: readonly string[] = [CODEX_PROVIDER, ANTHROPIC_PROVIDER, NAN_PROVIDER];
|
|
85
120
|
const PENDING_NOTE: Record<string, string> = {
|
|
86
121
|
[CODEX_PROVIDER]: "no usage yet · r to fetch",
|
|
122
|
+
[NAN_PROVIDER]: "no usage yet · r to fetch",
|
|
87
123
|
[ANTHROPIC_PROVIDER]: "usage arrives with the first response",
|
|
88
124
|
};
|
|
89
125
|
const UNSUPPORTED_NOTE = "no subscription usage for this provider";
|
|
@@ -174,6 +210,178 @@ export function parseUsageHeaders(headers: Record<string, string>, now: number):
|
|
|
174
210
|
return parseCodexHeaders(headers, now) ?? parseAnthropicHeaders(headers, now);
|
|
175
211
|
}
|
|
176
212
|
|
|
213
|
+
// NaN Cloud reports one allowance per model for the billing period, plus the
|
|
214
|
+
// rolling window the model applies on top of it. Percentages follow the
|
|
215
|
+
// dashboard exactly: tokens used over the period allowance, and window tokens
|
|
216
|
+
// over the full window budget. Every field is optional, because this payload
|
|
217
|
+
// lives outside NaN's published contract.
|
|
218
|
+
function quotaTimestamp(value: unknown): number | null {
|
|
219
|
+
if (typeof value === "number" && Number.isFinite(value) && value > 0) return value * 1000;
|
|
220
|
+
if (typeof value !== "string") return null;
|
|
221
|
+
const parsed = Date.parse(value);
|
|
222
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function quotaNumber(value: unknown, positive: boolean): number | undefined {
|
|
226
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
|
|
227
|
+
return positive ? (value > 0 ? value : undefined) : value >= 0 ? value : undefined;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function nanRollingWindow(raw: RawNanModel): UsageWindow | undefined {
|
|
231
|
+
const used = quotaNumber(raw.windowTokensUsed, false);
|
|
232
|
+
if (used === undefined) return undefined;
|
|
233
|
+
const budget = quotaNumber(raw.fullWindowTokens, true) ?? quotaNumber(raw.windowTokens, true) ?? NAN_DEFAULT_WINDOW_TOKENS;
|
|
234
|
+
const hours = quotaNumber(raw.windowHours, true) ?? NAN_DEFAULT_WINDOW_HOURS;
|
|
235
|
+
return { label: windowLabel(hours * HOUR), usedPercent: (used / budget) * 100, windowSeconds: hours * HOUR, resetAt: quotaTimestamp(raw.windowResetsAt) };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// The allowance the dashboard divides by is the full-period cap, because `cap`
|
|
239
|
+
// is the allowance of the period in progress and comes back prorated on a first
|
|
240
|
+
// period. A model that reports neither figure reports no allowance at all, which
|
|
241
|
+
// is a state the surfaces already know how to draw nothing for.
|
|
242
|
+
function nanEffectiveAllowance(raw: RawNanModel): number | undefined {
|
|
243
|
+
return quotaNumber(raw.fullCap, true) ?? quotaNumber(raw.cap, true);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// One allowance per metered model, weighted by that model's own cap. The raw
|
|
247
|
+
// numbers travel with the period window so the bar and the panel can aggregate
|
|
248
|
+
// without ever averaging percentages.
|
|
249
|
+
function nanPeriodWindow(tokensUsed: number, cap: number, resetAt: number | null, now: number): UsageWindow {
|
|
250
|
+
return {
|
|
251
|
+
label: NAN_PERIOD_LABEL,
|
|
252
|
+
usedPercent: (tokensUsed / cap) * 100,
|
|
253
|
+
windowSeconds: resetAt === null ? 0 : Math.max(0, Math.round((resetAt - now) / 1000)),
|
|
254
|
+
resetAt,
|
|
255
|
+
used: tokensUsed,
|
|
256
|
+
budget: cap,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function parseNanQuota(payload: unknown, now: number): ProviderUsage {
|
|
261
|
+
const raw = (payload ?? {}) as RawNanQuota;
|
|
262
|
+
const fallbackResetAt = quotaTimestamp(raw.periodEnd);
|
|
263
|
+
const limits: UsageLimit[] = [];
|
|
264
|
+
if (Array.isArray(raw.models)) {
|
|
265
|
+
for (const entry of raw.models) {
|
|
266
|
+
if (!entry || typeof entry !== "object") continue;
|
|
267
|
+
const model = entry as RawNanModel;
|
|
268
|
+
if (typeof model.model !== "string" || model.model.length === 0) continue;
|
|
269
|
+
const allowance = nanEffectiveAllowance(model);
|
|
270
|
+
// A model that reports no allowance is not drift — the dashboard draws
|
|
271
|
+
// nothing for it either, and the live payload carries such entries. A metered
|
|
272
|
+
// allowance whose usage cannot be read is drift: a partial snapshot would
|
|
273
|
+
// understate every aggregate it feeds, so the read fails whole and the last
|
|
274
|
+
// valid snapshot survives instead.
|
|
275
|
+
if (allowance === undefined) continue;
|
|
276
|
+
const tokensUsed = quotaNumber(model.tokensUsed, false);
|
|
277
|
+
if (tokensUsed === undefined) return { provider: NAN_PROVIDER, plan: undefined, limits: [], fetchedAt: now };
|
|
278
|
+
const resetAt = quotaTimestamp(model.periodEnd) ?? fallbackResetAt;
|
|
279
|
+
const windows: UsageWindow[] = [nanPeriodWindow(tokensUsed, allowance, resetAt, now)];
|
|
280
|
+
const rolling = nanRollingWindow(model);
|
|
281
|
+
if (rolling) windows.push(rolling);
|
|
282
|
+
limits.push({ name: model.model, windows, limitReached: tokensUsed >= allowance });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return { provider: NAN_PROVIDER, plan: undefined, limits, fetchedAt: now };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Aggregation. NaN reports one allowance per metered model and the payload
|
|
289
|
+
// order is the server's business, so surfaces pick by meaning, not by position.
|
|
290
|
+
function rawAllowance(limit: UsageLimit): UsageWindow | undefined {
|
|
291
|
+
const [first] = limit.windows;
|
|
292
|
+
if (!first || first.used === undefined || first.budget === undefined) return undefined;
|
|
293
|
+
return first.budget > 0 ? first : undefined;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// The leading alphabetic run of a model id: glm5.3-flash and glm5.2 are both
|
|
297
|
+
// "glm". Derived from the id the payload reports, never from a vendor list.
|
|
298
|
+
export function modelFamily(modelId: string): string {
|
|
299
|
+
return (/^[a-z]+/i.exec(modelId)?.[0] ?? modelId).toLowerCase();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Only NaN carries raw allowance numbers, so this one gate is what keeps Codex
|
|
303
|
+
// and Anthropic on exactly the rows and the meter they had before. One metered
|
|
304
|
+
// model is still a payload that carries them: the gate answers "does this
|
|
305
|
+
// provider report allowances", never "are there enough rows to sort", because
|
|
306
|
+
// a single allowance read as "no allowances" sent the bar back to whichever
|
|
307
|
+
// model the payload listed first.
|
|
308
|
+
export function allowanceGroupsSupported(limits: readonly UsageLimit[]): boolean {
|
|
309
|
+
return limits.length > 0 && limits.every((limit) => rawAllowance(limit) !== undefined);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function percentOf(limit: UsageLimit): number {
|
|
313
|
+
return limit.windows[0]?.usedPercent ?? 0;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const GROUP_SUFFIX = " total";
|
|
317
|
+
|
|
318
|
+
// A group is an allowance share, never an average of shares: Σused / Σbudget.
|
|
319
|
+
// It carries no reset, because its members close their own billing period on
|
|
320
|
+
// their own date, and a single reset would be a lie.
|
|
321
|
+
function allowanceTotal(name: string, limits: readonly UsageLimit[]): UsageLimit | undefined {
|
|
322
|
+
const windows = limits.map(rawAllowance).filter((window): window is UsageWindow => window !== undefined);
|
|
323
|
+
if (windows.length === 0 || windows.length !== limits.length) return undefined;
|
|
324
|
+
const used = windows.reduce((total, window) => total + (window.used ?? 0), 0);
|
|
325
|
+
const budget = windows.reduce((total, window) => total + (window.budget ?? 0), 0);
|
|
326
|
+
if (budget <= 0) return undefined;
|
|
327
|
+
return {
|
|
328
|
+
name,
|
|
329
|
+
windows: [{ label: NAN_PERIOD_LABEL, usedPercent: (used / budget) * 100, windowSeconds: 0, resetAt: null }],
|
|
330
|
+
limitReached: limits.some((limit) => limit.limitReached),
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// What the grouping is for now that the totals are gone: the order. A family
|
|
335
|
+
// stays together, families sort by what they consume and the members inside one
|
|
336
|
+
// follow the same rule, most used first. The account and family totals are the
|
|
337
|
+
// bar's fallback ladder only — they are never rows, because a total nobody can
|
|
338
|
+
// act on only costs space. Every row is a limit block, so nothing here
|
|
339
|
+
// introduces a shape the other providers do not already use.
|
|
340
|
+
export function groupUsageLimits(limits: readonly UsageLimit[]): UsageLimit[] {
|
|
341
|
+
if (!allowanceGroupsSupported(limits)) return [...limits];
|
|
342
|
+
const order: string[] = [];
|
|
343
|
+
const members = new Map<string, UsageLimit[]>();
|
|
344
|
+
for (const limit of limits) {
|
|
345
|
+
const family = modelFamily(limit.name);
|
|
346
|
+
if (!members.has(family)) {
|
|
347
|
+
members.set(family, []);
|
|
348
|
+
order.push(family);
|
|
349
|
+
}
|
|
350
|
+
members.get(family)?.push(limit);
|
|
351
|
+
}
|
|
352
|
+
return order
|
|
353
|
+
.map((family) => {
|
|
354
|
+
const sorted = [...(members.get(family) ?? [])].sort((a, b) => percentOf(b) - percentOf(a));
|
|
355
|
+
const total = allowanceTotal(`${family}${GROUP_SUFFIX}`, sorted);
|
|
356
|
+
return { percent: total?.windows[0]?.usedPercent ?? percentOf(sorted[0]), sorted };
|
|
357
|
+
})
|
|
358
|
+
.sort((a, b) => b.percent - a.percent)
|
|
359
|
+
.flatMap((family) => family.sorted);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// The bar follows the model the session is using: exact allowance, then its
|
|
363
|
+
// family, then the account total, then the first limit (which is what every
|
|
364
|
+
// provider without raw numbers keeps using, and what a missing model keeps).
|
|
365
|
+
export function selectUsageLimit(usage: ProviderUsage, activeModelId?: string): UsageLimit | undefined {
|
|
366
|
+
if (!activeModelId) return usage.limits[0];
|
|
367
|
+
const exact = usage.limits.find((limit) => limit.name === activeModelId);
|
|
368
|
+
if (exact) return exact;
|
|
369
|
+
if (allowanceGroupsSupported(usage.limits)) {
|
|
370
|
+
const family = modelFamily(activeModelId);
|
|
371
|
+
const members = usage.limits.filter((limit) => modelFamily(limit.name) === family);
|
|
372
|
+
// The family rung is about the name of the meter, not about printing a row,
|
|
373
|
+
// so a single member counts: its family is a closer statement of what the
|
|
374
|
+
// session is drawing from than the whole account.
|
|
375
|
+
if (members.length > 0) {
|
|
376
|
+
const total = allowanceTotal(`${family}${GROUP_SUFFIX}`, members);
|
|
377
|
+
if (total) return total;
|
|
378
|
+
}
|
|
379
|
+
const account = allowanceTotal(`${usage.provider}${GROUP_SUFFIX}`, usage.limits);
|
|
380
|
+
if (account) return account;
|
|
381
|
+
}
|
|
382
|
+
return usage.limits[0];
|
|
383
|
+
}
|
|
384
|
+
|
|
177
385
|
export function accountIdFromToken(token: string): string | undefined {
|
|
178
386
|
const parts = token.split(".");
|
|
179
387
|
if (parts.length !== 3) return undefined;
|
|
@@ -190,11 +398,12 @@ function paintMeter(percent: number, cells: number, theme: UsageTheme): string {
|
|
|
190
398
|
return paintGauge(percent, theme, cells);
|
|
191
399
|
}
|
|
192
400
|
|
|
193
|
-
export function renderUsageBar(usage: ProviderUsage, theme: UsageTheme): string | undefined {
|
|
194
|
-
const main = usage
|
|
401
|
+
export function renderUsageBar(usage: ProviderUsage, theme: UsageTheme, activeModelId?: string): string | undefined {
|
|
402
|
+
const main = selectUsageLimit(usage, activeModelId);
|
|
195
403
|
const [first, ...rest] = main?.windows ?? [];
|
|
196
404
|
if (!first) return undefined;
|
|
197
|
-
|
|
405
|
+
// An unlabeled window prints as the name, the meter and the percentage.
|
|
406
|
+
const head = [theme.fg(ROLE.LABEL, main.name), ...(first.label.length === 0 ? [] : [theme.fg(ROLE.LABEL, first.label)]), paintMeter(first.usedPercent, 8, theme), theme.fg(ROLE.PERCENT, `${Math.round(first.usedPercent)}%`)].join(" ");
|
|
198
407
|
const tail = rest.map((window) => `${theme.fg(ROLE.SEPARATOR, "·")} ${theme.fg(ROLE.LABEL, window.label)} ${theme.fg(ROLE.PERCENT, `${Math.round(window.usedPercent)}%`)}`);
|
|
199
408
|
return [head, ...tail].join(" ");
|
|
200
409
|
}
|
|
@@ -218,12 +427,19 @@ export function renderUsagePanel(usages: ProviderUsage[], theme: UsageTheme, wid
|
|
|
218
427
|
const mark = usage === activeUsage ? `${theme.fg(ROLE.LIMIT, ACTIVE_MARK)} ` : "";
|
|
219
428
|
const plan = usage.plan ? ` ${theme.fg(ROLE.SEPARATOR, "·")} ${theme.fg(ROLE.PLAN, usage.plan)}` : "";
|
|
220
429
|
lines.push(`${mark}${theme.fg(ROLE.PROVIDER, usage.provider)}${plan} ${theme.fg(ROLE.SEPARATOR, "·")} ${theme.fg(ROLE.RESET, updatedAgo(usage.fetchedAt, now))}`);
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
}
|
|
430
|
+
// One row per window: the limit name, its meter, its percentage and the reset
|
|
431
|
+
// that window reports, all on one line. A window without its own label (the
|
|
432
|
+
// model's allowance) is named by its limit alone, and one without a reset ends
|
|
433
|
+
// at its percentage, never on a dangling separator.
|
|
434
|
+
const rows = groupUsageLimits(usage.limits).flatMap((limit) =>
|
|
435
|
+
limit.windows.map((window) => ({ name: [limit.name, window.label].filter((part) => part.length > 0).join(" "), window })),
|
|
436
|
+
);
|
|
437
|
+
const nameWidth = rows.reduce((widest, row) => Math.max(widest, row.name.length), 0);
|
|
438
|
+
for (const row of rows) {
|
|
439
|
+
const percent = `${Math.round(row.window.usedPercent)}%`.padStart(4);
|
|
440
|
+
const reset = formatReset(row.window.resetAt, now);
|
|
441
|
+
const tail = reset.length > 0 ? ` ${theme.fg(ROLE.SEPARATOR, "·")} ${theme.fg(ROLE.RESET, reset)}` : "";
|
|
442
|
+
lines.push(` ${theme.fg(ROLE.LABEL, row.name.padEnd(nameWidth))} ${paintMeter(row.window.usedPercent, PANEL_METER_CELLS, theme)} ${theme.fg(ROLE.PERCENT, percent)}${tail}`);
|
|
227
443
|
}
|
|
228
444
|
}
|
|
229
445
|
return lines.map((line) => truncateToWidth(line, width, "…"));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gentle-pi",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.0",
|
|
4
4
|
"description": "Turn Pi into el Gentleman: a senior-architect development harness with SDD/OpenSpec, subagents, strict TDD evidence, review guardrails, and skill discovery.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
],
|
|
39
39
|
"scripts": {
|
|
40
40
|
"check:provider-contract": "node scripts/check-provider-contract.mjs",
|
|
41
|
+
"mirror:odd-routing": "node scripts/mirror-odd-routing.mjs",
|
|
41
42
|
"postinstall": "node scripts/install-gentle-ai.mjs",
|
|
42
43
|
"test": "node --experimental-strip-types --test tests/*.test.ts && pnpm run check:provider-contract && pnpm run test:harness",
|
|
43
44
|
"test:harness": "node --experimental-strip-types tests/runtime-harness.mjs",
|
|
@@ -996,6 +996,29 @@ export const NATIVE_CLI_CONTRACTS = Object.freeze({
|
|
|
996
996
|
// remain dark because neither is proven to reach the negotiated START
|
|
997
997
|
// path Pi consumes.
|
|
998
998
|
"3.1.0": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
|
|
999
|
+
// v3.2.1 changed the ODD orchestrator contract only (gentle-ai #4714
|
|
1000
|
+
// follow-up). Ground-truthed by diffing contracts/review-integration/v2 and
|
|
1001
|
+
// contracts/review-provider-contract between the v3.1.0 and v3.2.1 tags
|
|
1002
|
+
// in the gentle-ai source tree: zero bytes changed (provider contract
|
|
1003
|
+
// stays 1.2.0). Neither change touches the closed START/STATUS fields
|
|
1004
|
+
// this row negotiates, so it repeats 3.1.0 exactly. riskEvidence and hint
|
|
1005
|
+
// remain dark because neither is proven to reach the negotiated START
|
|
1006
|
+
// path Pi consumes.
|
|
1007
|
+
"3.2.1": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
|
|
1008
|
+
// v3.4.0 (gentle-pi never pinned the intervening v3.3.0 tag, so it gets no
|
|
1009
|
+
// row here) added capabilities/v2.6 and status/v8-v9, and extended
|
|
1010
|
+
// `review assess` with review_due/review_due_reason/next_transition
|
|
1011
|
+
// (gentle-ai #4714 follow-up). Ground-truthed by diffing
|
|
1012
|
+
// contracts/review-integration/v2 and contracts/review-provider-contract
|
|
1013
|
+
// between the v3.2.1 and v3.4.0 tags in the gentle-ai source tree: the
|
|
1014
|
+
// provider contract stays byte-identical at 1.2.0, and every
|
|
1015
|
+
// review-integration/v2 change is an additive superset (new optional
|
|
1016
|
+
// schema/fields) that decodeReviewStatusV3 and the capabilities
|
|
1017
|
+
// negotiator already accept without touching the closed START/STATUS
|
|
1018
|
+
// fields this row negotiates, so it repeats 3.2.1 exactly. riskEvidence
|
|
1019
|
+
// and hint remain dark because neither is proven to reach the negotiated
|
|
1020
|
+
// START path Pi consumes.
|
|
1021
|
+
"3.4.0": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
|
|
999
1022
|
});
|
|
1000
1023
|
|
|
1001
1024
|
|