@cortexkit/antigravity-auth-core 3.0.5 → 3.0.7
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/dist/account-manager.d.ts +15 -23
- package/dist/account-manager.d.ts.map +1 -1
- package/dist/account-manager.js +18 -32
- package/dist/account-manager.js.map +1 -1
- package/dist/account-migration.d.ts +1 -1
- package/dist/account-migration.d.ts.map +1 -1
- package/dist/account-migration.js +228 -53
- package/dist/account-migration.js.map +1 -1
- package/dist/account-repository-types.d.ts +1 -1
- package/dist/account-selector.d.ts +13 -27
- package/dist/account-selector.d.ts.map +1 -1
- package/dist/account-selector.js +38 -145
- package/dist/account-selector.js.map +1 -1
- package/dist/account-types.d.ts +1 -3
- package/dist/account-types.d.ts.map +1 -1
- package/dist/agy-transport.d.ts.map +1 -1
- package/dist/agy-transport.js +2 -0
- package/dist/agy-transport.js.map +1 -1
- package/dist/antigravity/oauth.d.ts +5 -1
- package/dist/antigravity/oauth.d.ts.map +1 -1
- package/dist/antigravity/oauth.js +43 -9
- package/dist/antigravity/oauth.js.map +1 -1
- package/dist/antigravity-command-menu.d.ts +4 -8
- package/dist/antigravity-command-menu.d.ts.map +1 -1
- package/dist/antigravity-command-menu.js +14 -37
- package/dist/antigravity-command-menu.js.map +1 -1
- package/dist/common-auth-embedded/claustrum/consumer.d.ts +44 -4
- package/dist/common-auth-embedded/claustrum/consumer.js +134 -2
- package/dist/common-auth-embedded/claustrum/custody.d.ts +33 -1
- package/dist/common-auth-embedded/claustrum/custody.js +17 -0
- package/dist/common-auth-embedded/claustrum/enrollment.d.ts +6 -1
- package/dist/common-auth-embedded/claustrum/enrollment.js +6 -1
- package/dist/common-auth-embedded/claustrum/index.d.ts +3 -2
- package/dist/common-auth-embedded/claustrum/index.js +5 -3
- package/dist/common-auth-embedded/claustrum/insula.d.ts +69 -0
- package/dist/common-auth-embedded/claustrum/insula.js +152 -0
- package/dist/common-auth-embedded/claustrum/roster.d.ts +13 -0
- package/dist/common-auth-embedded/claustrum/roster.js +33 -3
- package/dist/common-auth-embedded/fs/refresh-file-lock.d.ts +8 -2
- package/dist/common-auth-embedded/fs/refresh-file-lock.js +26 -12
- package/dist/common-auth-embedded/pi-slot/lock.js +7 -3
- package/dist/common-auth-embedded/quota/codec.d.ts +2 -0
- package/dist/common-auth-embedded/quota/codec.js +2 -1
- package/dist/common-auth-embedded/quota/format.d.ts +40 -3
- package/dist/common-auth-embedded/quota/format.js +196 -43
- package/dist/common-auth-embedded/quota/index.d.ts +4 -4
- package/dist/common-auth-embedded/quota/index.js +2 -2
- package/dist/common-auth-embedded/quota/map.d.ts +11 -0
- package/dist/common-auth-embedded/quota/map.js +36 -1
- package/dist/common-auth-embedded/quota/merge.d.ts +3 -1
- package/dist/common-auth-embedded/quota/merge.js +21 -5
- package/dist/common-auth-embedded/quota/projection.d.ts +2 -1
- package/dist/common-auth-embedded/quota/projection.js +3 -1
- package/dist/common-auth-embedded/source-output.json +117 -101
- package/dist/common-auth-embedded/store/pool.d.ts +1 -1
- package/dist/common-auth-embedded/store/pool.js +0 -1
- package/dist/common-auth-embedded/store/refresh-lock.d.ts +0 -1
- package/dist/common-auth-embedded/store/refresh-lock.js +0 -1
- package/dist/common-auth-embedded/store/schema.d.ts +2 -0
- package/dist/common-auth-embedded/store/schema.js +3 -2
- package/dist/common-auth-runtime.d.ts +2 -1
- package/dist/common-auth-runtime.d.ts.map +1 -1
- package/dist/common-auth-runtime.js +2 -1
- package/dist/common-auth-runtime.js.map +1 -1
- package/dist/constants.d.ts +1 -26
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +1 -37
- package/dist/constants.js.map +1 -1
- package/dist/credential-redaction.d.ts +8 -4
- package/dist/credential-redaction.d.ts.map +1 -1
- package/dist/credential-redaction.js +55 -28
- package/dist/credential-redaction.js.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/model-registry.d.ts +0 -1
- package/dist/model-registry.d.ts.map +1 -1
- package/dist/model-registry.js +3 -60
- package/dist/model-registry.js.map +1 -1
- package/dist/pi-paths.d.ts +14 -0
- package/dist/pi-paths.d.ts.map +1 -0
- package/dist/pi-paths.js +27 -0
- package/dist/pi-paths.js.map +1 -0
- package/dist/pre-response-deadline.d.ts +41 -0
- package/dist/pre-response-deadline.d.ts.map +1 -0
- package/dist/pre-response-deadline.js +150 -0
- package/dist/pre-response-deadline.js.map +1 -0
- package/dist/quota-manager.d.ts +7 -32
- package/dist/quota-manager.d.ts.map +1 -1
- package/dist/quota-manager.js +27 -76
- package/dist/quota-manager.js.map +1 -1
- package/dist/quota-types.d.ts +2 -11
- package/dist/quota-types.d.ts.map +1 -1
- package/dist/transform/index.d.ts +1 -1
- package/dist/transform/index.d.ts.map +1 -1
- package/dist/transform/index.js +1 -1
- package/dist/transform/index.js.map +1 -1
- package/dist/transform/model-resolver.d.ts +4 -28
- package/dist/transform/model-resolver.d.ts.map +1 -1
- package/dist/transform/model-resolver.js +33 -130
- package/dist/transform/model-resolver.js.map +1 -1
- package/dist/transform/types.d.ts +0 -5
- package/dist/transform/types.d.ts.map +1 -1
- package/dist/vault-account-source.d.ts +24 -3
- package/dist/vault-account-source.d.ts.map +1 -1
- package/dist/vault-account-source.js +39 -12
- package/dist/vault-account-source.js.map +1 -1
- package/dist/vault-folder-refusal.d.ts +32 -0
- package/dist/vault-folder-refusal.d.ts.map +1 -0
- package/dist/vault-folder-refusal.js +68 -0
- package/dist/vault-folder-refusal.js.map +1 -0
- package/dist/vault-grant-reach.d.ts +23 -0
- package/dist/vault-grant-reach.d.ts.map +1 -0
- package/dist/vault-grant-reach.js +15 -0
- package/dist/vault-grant-reach.js.map +1 -0
- package/dist/vault-provider-state.d.ts +1 -0
- package/dist/vault-provider-state.d.ts.map +1 -1
- package/dist/vault-provider-state.js +6 -0
- package/dist/vault-provider-state.js.map +1 -1
- package/package.json +3 -2
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { acquireRefreshFileLock, withLock, writeJsonAtomic, } from '../fs/index.js';
|
|
3
3
|
import { readPrivateText, windowsPrivateFiles } from '../fs/private-files.js';
|
|
4
|
-
import {
|
|
4
|
+
import { decodeQuotaMap, mergeQuotaObservation, } from '../quota/index.js';
|
|
5
5
|
import { ClaustrumConsumerError } from './errors.js';
|
|
6
6
|
import { acceptAccount, declineAccount, isDeclined, } from './interlock.js';
|
|
7
7
|
export const DEFAULT_ROUTE_PREFIX = 'vault:';
|
|
@@ -210,6 +210,7 @@ function optionalString(value) {
|
|
|
210
210
|
return value === undefined || typeof value === 'string';
|
|
211
211
|
}
|
|
212
212
|
function decodeRow(value) {
|
|
213
|
+
const quota = isRecord(value) ? decodeQuotaMap(value.quota) : undefined;
|
|
213
214
|
if (!isRecord(value) ||
|
|
214
215
|
typeof value.routeId !== 'string' ||
|
|
215
216
|
typeof value.credentialId !== 'string' ||
|
|
@@ -224,11 +225,14 @@ function decodeRow(value) {
|
|
|
224
225
|
(value.aliases !== undefined &&
|
|
225
226
|
(!Array.isArray(value.aliases) ||
|
|
226
227
|
!value.aliases.every((alias) => typeof alias === 'string'))) ||
|
|
227
|
-
(value.quota !== undefined &&
|
|
228
|
+
(value.quota !== undefined && quota === undefined) ||
|
|
228
229
|
(value.stale !== undefined && value.stale !== true) ||
|
|
229
230
|
(value.unclaimed !== undefined && value.unclaimed !== true))
|
|
230
231
|
return undefined;
|
|
231
|
-
return
|
|
232
|
+
return {
|
|
233
|
+
...value,
|
|
234
|
+
...(quota === undefined ? {} : { quota }),
|
|
235
|
+
};
|
|
232
236
|
}
|
|
233
237
|
const SKIPPED_REASONS = new Set([
|
|
234
238
|
'empty credential id',
|
|
@@ -435,6 +439,32 @@ export function recordVaultQuota(path, input) {
|
|
|
435
439
|
};
|
|
436
440
|
});
|
|
437
441
|
}
|
|
442
|
+
/**
|
|
443
|
+
* Merge a quota observation that belongs to an account rather than to a
|
|
444
|
+
* served credential, such as a background reading from insula. The write
|
|
445
|
+
* lands only while the row is still bound to that exact known account, so a
|
|
446
|
+
* reading taken before a replacement never reaches the successor. Unlike
|
|
447
|
+
* `recordVaultQuota` it never binds an account: a row without a known account
|
|
448
|
+
* is refused. Returns whether the observation was kept.
|
|
449
|
+
*/
|
|
450
|
+
export function recordVaultAccountQuota(path, input) {
|
|
451
|
+
return mutateVaultRoster(path, (current) => {
|
|
452
|
+
const row = current?.rows.find((entry) => entry.routeId === input.routeId);
|
|
453
|
+
if (!current ||
|
|
454
|
+
!row ||
|
|
455
|
+
row.accountIdentity === undefined ||
|
|
456
|
+
row.accountIdentity !== input.accountIdentity)
|
|
457
|
+
return { result: false };
|
|
458
|
+
const quota = mergeQuotaObservation(row.quota, input.observation);
|
|
459
|
+
return {
|
|
460
|
+
next: {
|
|
461
|
+
...current,
|
|
462
|
+
rows: current.rows.map((entry) => entry === row ? { ...entry, quota } : entry),
|
|
463
|
+
},
|
|
464
|
+
result: true,
|
|
465
|
+
};
|
|
466
|
+
});
|
|
467
|
+
}
|
|
438
468
|
/**
|
|
439
469
|
* Discover and commit the roster. The lease covers the list call and the
|
|
440
470
|
* commit, not just the write: an opaque view cannot tell a delayed old reply
|
|
@@ -13,7 +13,7 @@ export interface RefreshFileLock {
|
|
|
13
13
|
whenLost(): Promise<LockLoss>;
|
|
14
14
|
hasLost(): boolean;
|
|
15
15
|
}
|
|
16
|
-
|
|
16
|
+
type RefreshFileLockOptions = {
|
|
17
17
|
name: string;
|
|
18
18
|
ttlMs: number;
|
|
19
19
|
/**
|
|
@@ -22,7 +22,6 @@ export declare function acquireRefreshFileLock(options: {
|
|
|
22
22
|
* be one host's path.
|
|
23
23
|
*/
|
|
24
24
|
path: string;
|
|
25
|
-
now?: () => number;
|
|
26
25
|
renew?: boolean;
|
|
27
26
|
renewIntervalMs?: number;
|
|
28
27
|
/** Notifies a live-owner refusal without awaiting; observer failures are ignored. */
|
|
@@ -30,4 +29,11 @@ export declare function acquireRefreshFileLock(options: {
|
|
|
30
29
|
/** Synchronous, never awaited; throws and returned promise/thenable rejections are isolated. */
|
|
31
30
|
onRenewalTimer?: (event: 'scheduled' | 'cancelled') => void;
|
|
32
31
|
onStep?: (step: 'stale-marker-stat' | 'stale-marker-claimed' | 'stale-lock-observed' | 'lock-read-ready' | 'lock-missing' | 'stale-lock-confirmed' | 'stale-lock-removed' | 'stale-lock-recreated' | 'eviction-marker-acquired' | 'renewal-owner-confirmed' | 'renewal-marker-unavailable' | 'renewal-write-fenced' | 'renewal-write-ready' | 'renewal-write-finished' | 'relinquish-read' | 'renewal-finished' | 'release-owner-confirmed') => void | Promise<void>;
|
|
32
|
+
};
|
|
33
|
+
/** Filesystem leases share wall time across all callers and processes. */
|
|
34
|
+
export declare function acquireRefreshFileLock(options: RefreshFileLockOptions): Promise<RefreshFileLock | null>;
|
|
35
|
+
/** @internal Clock seam for lease tests; not exported by the package's fs entry point. */
|
|
36
|
+
export declare function acquireRefreshFileLockWithClock(options: RefreshFileLockOptions & {
|
|
37
|
+
now?: () => number;
|
|
33
38
|
}): Promise<RefreshFileLock | null>;
|
|
39
|
+
export {};
|
|
@@ -1,8 +1,25 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { mkdir, open, readFile, rename, rm, stat
|
|
2
|
+
import { mkdir, open, readFile, rename, rm, stat } from 'node:fs/promises';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
import { createPrivateBytes, createPrivateDirectoryExclusive, ensurePrivateDirectory, PrivateFileError, privateOperation, readPrivateText, verifyPrivate, windowsPrivateFiles, writePrivateAtomic, } from './private-files.js';
|
|
5
5
|
import { LockOwnershipError, lockPathFor } from './with-lock.js';
|
|
6
|
+
/**
|
|
7
|
+
* Create a new owner-only file and write `text` into it, failing with EEXIST if
|
|
8
|
+
* the file already exists. Writing through the handle rather than path-based
|
|
9
|
+
* `writeFile(path, …, { flag: 'wx' })` matters: Bun's path form also truncates
|
|
10
|
+
* the file it has just created, and on some Linux filesystems (XFS under load)
|
|
11
|
+
* that redundant ftruncate stalls for hundreds of milliseconds, inside every
|
|
12
|
+
* lock acquisition.
|
|
13
|
+
*/
|
|
14
|
+
async function createExclusiveText(path, text) {
|
|
15
|
+
const handle = await open(path, 'wx', 0o600);
|
|
16
|
+
try {
|
|
17
|
+
await handle.writeFile(text, 'utf8');
|
|
18
|
+
}
|
|
19
|
+
finally {
|
|
20
|
+
await handle.close();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
6
23
|
const setRefreshLockRenewalTimeout = globalThis.setTimeout.bind(globalThis);
|
|
7
24
|
const clearRefreshLockRenewalTimeout = globalThis.clearTimeout.bind(globalThis);
|
|
8
25
|
// A concurrent contender renaming the freshly-created eviction-marker directory
|
|
@@ -14,7 +31,12 @@ export function isLostMarkerRaceError(error) {
|
|
|
14
31
|
return (code === 'ENOENT' ||
|
|
15
32
|
(!windowsPrivateFiles && (code === 'EINVAL' || code === 'ENOTDIR')));
|
|
16
33
|
}
|
|
17
|
-
|
|
34
|
+
/** Filesystem leases share wall time across all callers and processes. */
|
|
35
|
+
export function acquireRefreshFileLock(options) {
|
|
36
|
+
return acquireRefreshFileLockWithClock({ ...options, now: Date.now });
|
|
37
|
+
}
|
|
38
|
+
/** @internal Clock seam for lease tests; not exported by the package's fs entry point. */
|
|
39
|
+
export async function acquireRefreshFileLockWithClock(options) {
|
|
18
40
|
const lockPath = lockPathFor(options.path, options.name);
|
|
19
41
|
const legacyOwnerPath = join(lockPath, 'owner.json');
|
|
20
42
|
const ownerId = randomUUID();
|
|
@@ -152,11 +174,7 @@ export async function acquireRefreshFileLock(options) {
|
|
|
152
174
|
if (windowsPrivateFiles)
|
|
153
175
|
await createPrivateBytes(lockPath, bytes);
|
|
154
176
|
else
|
|
155
|
-
await
|
|
156
|
-
encoding: 'utf8',
|
|
157
|
-
mode: 0o600,
|
|
158
|
-
flag: 'wx',
|
|
159
|
-
});
|
|
177
|
+
await createExclusiveText(lockPath, bytes);
|
|
160
178
|
}
|
|
161
179
|
async function leaseContention(error) {
|
|
162
180
|
const code = error.code;
|
|
@@ -304,11 +322,7 @@ export async function acquireRefreshFileLock(options) {
|
|
|
304
322
|
if (windowsPrivateFiles)
|
|
305
323
|
await createPrivateBytes(evictOwnerPath, bytes);
|
|
306
324
|
else
|
|
307
|
-
await
|
|
308
|
-
encoding: 'utf8',
|
|
309
|
-
mode: 0o600,
|
|
310
|
-
flag: 'wx',
|
|
311
|
-
});
|
|
325
|
+
await createExclusiveText(evictOwnerPath, bytes);
|
|
312
326
|
}
|
|
313
327
|
catch (error) {
|
|
314
328
|
// A competing contender can rename our just-created marker directory
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { rmdirSync, statSync } from 'node:fs';
|
|
1
|
+
import { rmdirSync, statSync, utimesSync } from 'node:fs';
|
|
2
2
|
import { mkdir, rmdir, stat, utimes } from 'node:fs/promises';
|
|
3
3
|
// Protocol source: proper-lockfile 4.1.2 lib/lockfile.js and lib/mtime-precision.js;
|
|
4
4
|
// line-level citations and the Pi lock options are recorded in docs/sources.md.
|
|
@@ -135,7 +135,9 @@ export async function acquirePiLock(authPath, onCompromised, onRenew) {
|
|
|
135
135
|
return;
|
|
136
136
|
let observed;
|
|
137
137
|
try {
|
|
138
|
-
|
|
138
|
+
// Durable writes can occupy every async filesystem worker in fsync.
|
|
139
|
+
// Renew on the calling thread so their queue cannot consume the lease.
|
|
140
|
+
observed = statSync(path);
|
|
139
141
|
}
|
|
140
142
|
catch (error) {
|
|
141
143
|
retry(error);
|
|
@@ -151,12 +153,14 @@ export async function acquirePiLock(authPath, onCompromised, onRenew) {
|
|
|
151
153
|
}
|
|
152
154
|
const next = new Date(precision === 's' ? Math.ceil(Date.now() / 1_000) * 1_000 : Date.now());
|
|
153
155
|
try {
|
|
154
|
-
|
|
156
|
+
utimesSync(path, next, next);
|
|
155
157
|
}
|
|
156
158
|
catch (error) {
|
|
157
159
|
retry(error);
|
|
158
160
|
return;
|
|
159
161
|
}
|
|
162
|
+
// A metadata syscall can itself stall; never confirm an expired lease.
|
|
163
|
+
assertFresh();
|
|
160
164
|
if (released)
|
|
161
165
|
return;
|
|
162
166
|
mtime = next.getTime();
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export interface QuotaCodec {
|
|
2
2
|
readonly validate: (value: unknown) => boolean;
|
|
3
|
+
/** Normalize persisted data before validation; older codecs may omit this. */
|
|
4
|
+
readonly decode?: (value: unknown) => unknown;
|
|
3
5
|
readonly merge: (stored: unknown, observation: unknown) => unknown;
|
|
4
6
|
}
|
|
5
7
|
/**
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isQuotaMap } from './map.js';
|
|
1
|
+
import { decodeQuotaMap, isQuotaMap } from './map.js';
|
|
2
2
|
import { mergeQuotaObservation } from './merge.js';
|
|
3
3
|
/**
|
|
4
4
|
* The quota codec the account-pool store (the `/store` subpath) takes when it
|
|
@@ -8,5 +8,6 @@ import { mergeQuotaObservation } from './merge.js';
|
|
|
8
8
|
*/
|
|
9
9
|
export const quotaCodec = Object.freeze({
|
|
10
10
|
validate: isQuotaMap,
|
|
11
|
+
decode: decodeQuotaMap,
|
|
11
12
|
merge: mergeQuotaObservation,
|
|
12
13
|
});
|
|
@@ -11,8 +11,11 @@ export declare const NO_LIMITS_REPORTED = "no limits reported";
|
|
|
11
11
|
/**
|
|
12
12
|
* `compact` fits a footer or a sidebar row: each window's figure, the credit
|
|
13
13
|
* budget and the stale marker, without reset times. `full` adds each reset.
|
|
14
|
+
* `narrow` fits one line per account in a narrow sidebar: `5h 92% · 7d 66%
|
|
15
|
+
* · Fa 98% left`, the account-wide windows plus at most one model-scoped
|
|
16
|
+
* window or the budget (as `cr`), and no stale marker (see `isQuotaStale`).
|
|
14
17
|
*/
|
|
15
|
-
export type QuotaTextForm = 'compact' | 'full';
|
|
18
|
+
export type QuotaTextForm = 'compact' | 'full' | 'narrow';
|
|
16
19
|
export interface QuotaTextOptions {
|
|
17
20
|
/** The current time in ms, for reset countdowns and the stale marker. */
|
|
18
21
|
now: number;
|
|
@@ -21,6 +24,26 @@ export interface QuotaTextOptions {
|
|
|
21
24
|
/** Defaults to `QUOTA_STALE_AFTER_MS`. */
|
|
22
25
|
staleAfterMs?: number;
|
|
23
26
|
}
|
|
27
|
+
export interface QuotaWindowPart {
|
|
28
|
+
kind: 'window' | 'budget';
|
|
29
|
+
/** The display name of a quota window, or `credits` for the budget. */
|
|
30
|
+
name: string;
|
|
31
|
+
/** The shared figure wording, including `credits` for budget figures. */
|
|
32
|
+
figure: string;
|
|
33
|
+
/** The unrounded remaining percentage, clamped to 0–100, when known. */
|
|
34
|
+
remainingPercent?: number;
|
|
35
|
+
/** A bare relative reset span such as `4d`, only when the reset is future. */
|
|
36
|
+
resetIn?: string;
|
|
37
|
+
checkedAt: number;
|
|
38
|
+
/** Whether the reset followed this reading and has arrived by `now`. */
|
|
39
|
+
resetSinceReading: boolean;
|
|
40
|
+
}
|
|
41
|
+
export interface QuotaParts {
|
|
42
|
+
/** Reported windows in display order, followed by the budget when present. */
|
|
43
|
+
windows: QuotaWindowPart[];
|
|
44
|
+
/** The shared checked-age wording, present only when the reading is stale. */
|
|
45
|
+
age?: string;
|
|
46
|
+
}
|
|
24
47
|
/**
|
|
25
48
|
* The name a window is shown under. A general window (scope `all`) is named
|
|
26
49
|
* by its stored length; a model-scoped window keeps the label the plugin
|
|
@@ -29,17 +52,31 @@ export interface QuotaTextOptions {
|
|
|
29
52
|
* were recorded) falls back to its label.
|
|
30
53
|
*/
|
|
31
54
|
export declare function quotaWindowName(limit: Pick<ProjectedLimit, 'scope' | 'label' | 'windowMinutes'>): string;
|
|
55
|
+
/**
|
|
56
|
+
* Structured quota wording for layouts that need one row per window. The
|
|
57
|
+
* windows use the same filtering and order as `quotaTextParts`; future resets
|
|
58
|
+
* and the optional age keep the shared formatter's wording.
|
|
59
|
+
*/
|
|
60
|
+
export declare function quotaWindowParts(quota: ProjectedQuota | undefined, options: QuotaTextOptions): QuotaParts;
|
|
61
|
+
/**
|
|
62
|
+
* Whether the reading is stale: true exactly when the compact and full forms
|
|
63
|
+
* would end with `checked … ago`. The narrow form leaves that age out to stay
|
|
64
|
+
* short, so a caller drawing a narrow line uses this to dim it instead.
|
|
65
|
+
*/
|
|
66
|
+
export declare function isQuotaStale(quota: ProjectedQuota | undefined, options: QuotaTextOptions): boolean;
|
|
32
67
|
/**
|
|
33
68
|
* The pieces of a quota text, in order: one per reported window
|
|
34
69
|
* (`5h 58% left, resets 2h`), then the credit budget when there is one, then
|
|
35
70
|
* `checked 3h ago` when the oldest piece shown is stale. Empty when nothing
|
|
36
71
|
* was reported; `formatQuota` turns that into a sentence. A caller printing
|
|
37
|
-
* one line per window (the terminal auth menu) uses these directly.
|
|
72
|
+
* one line per window (the terminal auth menu) uses these directly. In the
|
|
73
|
+
* narrow form the pieces are the narrow line's (`5h 92%`, `7d 66% left`).
|
|
38
74
|
*/
|
|
39
75
|
export declare function quotaTextParts(quota: ProjectedQuota | undefined, options: QuotaTextOptions): string[];
|
|
40
76
|
/**
|
|
41
77
|
* A row's quota as one line: `5h 58% left · 7d 90% left` in the compact
|
|
42
|
-
* form, with `, resets 2h` after each window in the full form
|
|
78
|
+
* form, with `, resets 2h` after each window in the full form, and
|
|
79
|
+
* `5h 58% · 7d 90% left` in the narrow form. With nothing
|
|
43
80
|
* reported it is `NO_QUOTA_READING`, or `NO_LIMITS_REPORTED` when the
|
|
44
81
|
* provider answered without a limit for any window.
|
|
45
82
|
*/
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The one quota wording every surface shows: the command menu's dialogs on
|
|
2
2
|
// OpenCode and Pi, the terminal auth menu, and the plugins' sidebars. Each of
|
|
3
|
-
// them calls `formatQuota`
|
|
4
|
-
//
|
|
3
|
+
// them calls `formatQuota`, `quotaTextParts`, or `quotaWindowParts`, so the
|
|
4
|
+
// wording cannot drift between plugins or between a dialog and a sidebar.
|
|
5
5
|
//
|
|
6
6
|
// The rules: a window is named by its length (`5h`, `7d`), or, for a window
|
|
7
7
|
// that binds only one model family, by the label the plugin stored it under.
|
|
@@ -22,18 +22,23 @@ export const NO_QUOTA_READING = 'no quota reading yet';
|
|
|
22
22
|
*/
|
|
23
23
|
export const NO_LIMITS_REPORTED = 'no limits reported';
|
|
24
24
|
/**
|
|
25
|
-
* A
|
|
26
|
-
*
|
|
27
|
-
*
|
|
25
|
+
* A time span in at most its two largest units: `45m`, `2h29m`, `5h`, `3d23h`,
|
|
26
|
+
* `7d`. Every unit is rounded down, so a reset is never shown later than it is;
|
|
27
|
+
* the one exception is a span under a minute, which reads `1m` rather than
|
|
28
|
+
* zero. A zero second unit is left out.
|
|
28
29
|
*/
|
|
29
30
|
function shortSpan(ms) {
|
|
30
|
-
const minutes = Math.max(1, Math.
|
|
31
|
+
const minutes = Math.max(1, Math.floor(ms / 60_000));
|
|
31
32
|
if (minutes < 60)
|
|
32
33
|
return `${minutes}m`;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
if (minutes < 1440) {
|
|
35
|
+
const hours = Math.floor(minutes / 60);
|
|
36
|
+
const rest = minutes % 60;
|
|
37
|
+
return rest === 0 ? `${hours}h` : `${hours}h${rest}m`;
|
|
38
|
+
}
|
|
39
|
+
const days = Math.floor(minutes / 1440);
|
|
40
|
+
const hours = Math.floor((minutes % 1440) / 60);
|
|
41
|
+
return hours === 0 ? `${days}d` : `${days}d${hours}h`;
|
|
37
42
|
}
|
|
38
43
|
/** A stored window length in minutes as its exact name: `5h`, `7d`, `90m`. */
|
|
39
44
|
function lengthName(minutes) {
|
|
@@ -55,17 +60,24 @@ export function quotaWindowName(limit) {
|
|
|
55
60
|
return limit.label;
|
|
56
61
|
return lengthName(limit.windowMinutes);
|
|
57
62
|
}
|
|
63
|
+
function clampPercent(remaining) {
|
|
64
|
+
return Math.min(100, Math.max(0, remaining));
|
|
65
|
+
}
|
|
66
|
+
/** A remaining percentage as the whole number every form shows. */
|
|
67
|
+
function wholePercent(remaining) {
|
|
68
|
+
return Math.round(clampPercent(remaining));
|
|
69
|
+
}
|
|
58
70
|
function percentLeft(remaining) {
|
|
59
|
-
return `${
|
|
71
|
+
return `${wholePercent(remaining)}% left`;
|
|
60
72
|
}
|
|
61
|
-
function
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
73
|
+
function resetDetails(resetsAt, checkedAt, now) {
|
|
74
|
+
const at = resetsAt === undefined ? Number.NaN : Date.parse(resetsAt);
|
|
75
|
+
return {
|
|
76
|
+
...(Number.isFinite(at) && at > now
|
|
77
|
+
? { resetIn: shortSpan(at - now) }
|
|
78
|
+
: {}),
|
|
79
|
+
resetSinceReading: Number.isFinite(at) && at > checkedAt && at <= now,
|
|
80
|
+
};
|
|
69
81
|
}
|
|
70
82
|
/** Shortest window first, so `5h` reads before `7d`; unknown lengths last. */
|
|
71
83
|
function displayOrder(left, right) {
|
|
@@ -85,46 +97,187 @@ function displayOrder(left, right) {
|
|
|
85
97
|
return left.label < right.label ? -1 : left.label > right.label ? 1 : 0;
|
|
86
98
|
}
|
|
87
99
|
/**
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
* was reported; `formatQuota` turns that into a sentence. A caller printing
|
|
92
|
-
* one line per window (the terminal auth menu) uses these directly.
|
|
100
|
+
* Structured quota wording for layouts that need one row per window. The
|
|
101
|
+
* windows use the same filtering and order as `quotaTextParts`; future resets
|
|
102
|
+
* and the optional age keep the shared formatter's wording.
|
|
93
103
|
*/
|
|
94
|
-
export function
|
|
104
|
+
export function quotaWindowParts(quota, options) {
|
|
105
|
+
const { windows, age } = scopedWindowParts(quota, options);
|
|
106
|
+
const parts = windows.map(({ part }) => part);
|
|
107
|
+
return age === undefined ? { windows: parts } : { windows: parts, age };
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* `quotaWindowParts` with each window's scope kept beside it. The narrow form
|
|
111
|
+
* needs the scope to name and pick model-scoped windows; the public parts do
|
|
112
|
+
* not carry it, so their shape stays as it was.
|
|
113
|
+
*/
|
|
114
|
+
function scopedWindowParts(quota, options) {
|
|
95
115
|
if (!quota)
|
|
96
|
-
return [];
|
|
116
|
+
return { windows: [] };
|
|
97
117
|
const { now } = options;
|
|
98
|
-
const full = (options.form ?? 'full') === 'full';
|
|
99
|
-
const parts = [];
|
|
100
|
-
const times = [];
|
|
101
118
|
const readings = quota.limits
|
|
102
119
|
.filter((limit) => limit.kind === 'reading' && limit.remainingPercent !== undefined)
|
|
103
120
|
.sort(displayOrder);
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
121
|
+
const windows = readings.map((limit) => {
|
|
122
|
+
const remainingPercent = clampPercent(limit.remainingPercent ?? 0);
|
|
123
|
+
return {
|
|
124
|
+
scope: limit.scope,
|
|
125
|
+
part: {
|
|
126
|
+
kind: 'window',
|
|
127
|
+
name: quotaWindowName(limit),
|
|
128
|
+
figure: percentLeft(limit.remainingPercent ?? 0),
|
|
129
|
+
remainingPercent,
|
|
130
|
+
checkedAt: limit.checkedAt,
|
|
131
|
+
...resetDetails(limit.resetsAt, limit.checkedAt, now),
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
});
|
|
108
135
|
const budget = quota.budget;
|
|
109
136
|
if (budget) {
|
|
110
|
-
|
|
137
|
+
const remainingPercent = budget.remainingPercent;
|
|
111
138
|
const figure = budget.reached
|
|
112
139
|
? 'credits spent'
|
|
113
|
-
:
|
|
114
|
-
? `credits ${percentLeft(
|
|
140
|
+
: remainingPercent !== undefined
|
|
141
|
+
? `credits ${percentLeft(remainingPercent)}`
|
|
115
142
|
: 'credits available';
|
|
116
|
-
|
|
143
|
+
windows.push({
|
|
144
|
+
scope: ALL_SCOPE,
|
|
145
|
+
spent: budget.reached,
|
|
146
|
+
part: {
|
|
147
|
+
kind: 'budget',
|
|
148
|
+
name: 'credits',
|
|
149
|
+
figure,
|
|
150
|
+
...(remainingPercent === undefined
|
|
151
|
+
? {}
|
|
152
|
+
: { remainingPercent: clampPercent(remainingPercent) }),
|
|
153
|
+
checkedAt: budget.checkedAt,
|
|
154
|
+
...resetDetails(budget.resetsAt, budget.checkedAt, now),
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
if (windows.length > 0) {
|
|
159
|
+
const age = now - Math.min(...windows.map(({ part }) => part.checkedAt));
|
|
160
|
+
if (age > (options.staleAfterMs ?? QUOTA_STALE_AFTER_MS)) {
|
|
161
|
+
return { windows, age: `checked ${shortSpan(age)} ago` };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return { windows };
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Whether the reading is stale: true exactly when the compact and full forms
|
|
168
|
+
* would end with `checked … ago`. The narrow form leaves that age out to stay
|
|
169
|
+
* short, so a caller drawing a narrow line uses this to dim it instead.
|
|
170
|
+
*/
|
|
171
|
+
export function isQuotaStale(quota, options) {
|
|
172
|
+
return scopedWindowParts(quota, options).age !== undefined;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* The model a scope names, as a display name: any namespace before a colon
|
|
176
|
+
* is dropped (`model:claude-opus-4` → `claude-opus-4`) and the first letter is
|
|
177
|
+
* capitalized (`fable` → `Fable`); the rest stays as written.
|
|
178
|
+
*/
|
|
179
|
+
function scopeDisplayName(scope) {
|
|
180
|
+
const model = scope.slice(scope.indexOf(':') + 1);
|
|
181
|
+
return model.charAt(0).toUpperCase() + model.slice(1);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Short names for the model scopes in one row: the first two letters of each
|
|
185
|
+
* display name. Names that would read the same for different models are each
|
|
186
|
+
* extended one letter at a time until they differ (`Fable` and `Fabric` read
|
|
187
|
+
* `Fabl` and `Fabr`); a name that runs out of letters stays whole.
|
|
188
|
+
*/
|
|
189
|
+
function scopeAbbreviations(scopes) {
|
|
190
|
+
const names = [...new Set(scopes.map(scopeDisplayName))];
|
|
191
|
+
const lengths = new Map(names.map((name) => [name, 2]));
|
|
192
|
+
const short = (name) => name.slice(0, lengths.get(name));
|
|
193
|
+
for (;;) {
|
|
194
|
+
const byShort = new Map();
|
|
195
|
+
for (const name of names) {
|
|
196
|
+
byShort.set(short(name), [...(byShort.get(short(name)) ?? []), name]);
|
|
197
|
+
}
|
|
198
|
+
let extended = false;
|
|
199
|
+
for (const group of byShort.values()) {
|
|
200
|
+
if (group.length < 2)
|
|
201
|
+
continue;
|
|
202
|
+
for (const name of group) {
|
|
203
|
+
const length = lengths.get(name) ?? 2;
|
|
204
|
+
if (length < name.length) {
|
|
205
|
+
lengths.set(name, length + 1);
|
|
206
|
+
extended = true;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (!extended)
|
|
211
|
+
break;
|
|
117
212
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
213
|
+
return new Map(scopes.map((scope) => [scope, short(scopeDisplayName(scope))]));
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* The narrow form's pieces: every account-wide window as `5h 92%`, then at
|
|
217
|
+
* most one more item, with ` left` added once to the last piece. That item is
|
|
218
|
+
* the tightest (least remaining) of the model-scoped windows and the credit
|
|
219
|
+
* budget, so the line stays bounded whatever the account carries: a scoped
|
|
220
|
+
* window reads under its short model name (`Fa 98%`), the budget as `cr 75%`
|
|
221
|
+
* (`cr 0%` when spent). On a tie a scoped window wins over the budget, and
|
|
222
|
+
* among scoped windows the first in display order wins. The choice depends
|
|
223
|
+
* only on the reading, never on a width. A budget without a percentage cannot
|
|
224
|
+
* be chosen; when nothing at all is shown it keeps its compact wording
|
|
225
|
+
* (`credits available`). No age is added; `isQuotaStale` says whether the
|
|
226
|
+
* reading is stale. A window whose reset arrived after the reading keeps its
|
|
227
|
+
* figure, as in the other forms.
|
|
228
|
+
*/
|
|
229
|
+
function narrowTextParts(quota, options) {
|
|
230
|
+
const { windows } = scopedWindowParts(quota, options);
|
|
231
|
+
const general = windows.filter(({ part, scope }) => part.kind === 'window' && scope === ALL_SCOPE);
|
|
232
|
+
const scoped = windows.filter(({ part, scope }) => part.kind === 'window' && scope !== ALL_SCOPE);
|
|
233
|
+
const abbreviations = scopeAbbreviations(scoped.map(({ scope }) => scope));
|
|
234
|
+
// The items competing for the one slot after the account-wide windows, in
|
|
235
|
+
// tie-break order: scoped windows in display order, then the budget. A
|
|
236
|
+
// spent budget counts as 0% left; one without a percentage cannot compete.
|
|
237
|
+
const budget = windows.find(({ part }) => part.kind === 'budget');
|
|
238
|
+
const budgetLeft = budget?.spent ? 0 : budget?.part.remainingPercent;
|
|
239
|
+
const extras = [
|
|
240
|
+
...scoped.map(({ scope, part }) => ({
|
|
241
|
+
label: abbreviations.get(scope) ?? scope,
|
|
242
|
+
remaining: part.remainingPercent ?? 0,
|
|
243
|
+
})),
|
|
244
|
+
...(budgetLeft === undefined
|
|
245
|
+
? []
|
|
246
|
+
: [{ label: 'cr', remaining: budgetLeft }]),
|
|
247
|
+
];
|
|
248
|
+
// Strictly lower wins, so on a tie the earlier item keeps the slot.
|
|
249
|
+
const tightest = extras.reduce((best, item) => best === undefined || item.remaining < best.remaining ? item : best, undefined);
|
|
250
|
+
const pieces = general.map(({ part }) => `${part.name} ${wholePercent(part.remainingPercent ?? 0)}%`);
|
|
251
|
+
if (tightest) {
|
|
252
|
+
pieces.push(`${tightest.label} ${wholePercent(tightest.remaining)}%`);
|
|
122
253
|
}
|
|
123
|
-
|
|
254
|
+
if (pieces.length === 0)
|
|
255
|
+
return windows.map(({ part }) => part.figure);
|
|
256
|
+
return pieces.map((piece, index) => index === pieces.length - 1 ? `${piece} left` : piece);
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* The pieces of a quota text, in order: one per reported window
|
|
260
|
+
* (`5h 58% left, resets 2h`), then the credit budget when there is one, then
|
|
261
|
+
* `checked 3h ago` when the oldest piece shown is stale. Empty when nothing
|
|
262
|
+
* was reported; `formatQuota` turns that into a sentence. A caller printing
|
|
263
|
+
* one line per window (the terminal auth menu) uses these directly. In the
|
|
264
|
+
* narrow form the pieces are the narrow line's (`5h 92%`, `7d 66% left`).
|
|
265
|
+
*/
|
|
266
|
+
export function quotaTextParts(quota, options) {
|
|
267
|
+
if (options.form === 'narrow')
|
|
268
|
+
return narrowTextParts(quota, options);
|
|
269
|
+
const parts = quotaWindowParts(quota, options);
|
|
270
|
+
const full = (options.form ?? 'full') === 'full';
|
|
271
|
+
const text = parts.windows.map((part) => {
|
|
272
|
+
const reset = full && part.resetIn ? `, resets ${part.resetIn}` : '';
|
|
273
|
+
return `${part.kind === 'budget' ? '' : `${part.name} `}${part.figure}${reset}`;
|
|
274
|
+
});
|
|
275
|
+
return parts.age === undefined ? text : [...text, parts.age];
|
|
124
276
|
}
|
|
125
277
|
/**
|
|
126
278
|
* A row's quota as one line: `5h 58% left · 7d 90% left` in the compact
|
|
127
|
-
* form, with `, resets 2h` after each window in the full form
|
|
279
|
+
* form, with `, resets 2h` after each window in the full form, and
|
|
280
|
+
* `5h 58% · 7d 90% left` in the narrow form. With nothing
|
|
128
281
|
* reported it is `NO_QUOTA_READING`, or `NO_LIMITS_REPORTED` when the
|
|
129
282
|
* provider answered without a limit for any window.
|
|
130
283
|
*/
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
export type { QuotaCodec } from './codec.js';
|
|
2
2
|
export { quotaCodec } from './codec.js';
|
|
3
|
-
export type { QuotaTextForm, QuotaTextOptions } from './format.js';
|
|
4
|
-
export { formatQuota, NO_LIMITS_REPORTED, NO_QUOTA_READING, QUOTA_STALE_AFTER_MS, quotaTextParts, quotaWindowName, } from './format.js';
|
|
5
|
-
export type { CreditBudgetCleared, CreditBudgetEntry, CreditBudgetReading, QuotaAbsentEntry, QuotaEntry, QuotaMap, QuotaReadingEntry, QuotaRetiredEntry, } from './map.js';
|
|
6
|
-
export { ALL_SCOPE, DEFAULT_REQUIRED_LABELS, emptyQuotaMap, isQuotaMap, } from './map.js';
|
|
3
|
+
export type { QuotaParts, QuotaTextForm, QuotaTextOptions, QuotaWindowPart, } from './format.js';
|
|
4
|
+
export { formatQuota, isQuotaStale, NO_LIMITS_REPORTED, NO_QUOTA_READING, QUOTA_STALE_AFTER_MS, quotaTextParts, quotaWindowName, quotaWindowParts, } from './format.js';
|
|
5
|
+
export type { CreditBudgetCleared, CreditBudgetEntry, CreditBudgetReading, QuotaAbsentEntry, QuotaCounter, QuotaEntry, QuotaMap, QuotaReadingEntry, QuotaRetiredEntry, } from './map.js';
|
|
6
|
+
export { ALL_SCOPE, DEFAULT_REQUIRED_LABELS, decodeQuotaMap, emptyQuotaMap, isQuotaMap, } from './map.js';
|
|
7
7
|
export type { ObservedBudget, ObservedPair, ObservedReading, QuotaObservation, } from './merge.js';
|
|
8
8
|
export { isQuotaObservation, mergeQuotaObservation, QuotaCodecError, } from './merge.js';
|
|
9
9
|
export type { ExhaustionReset, ProjectedBudget, ProjectedLimit, ProjectedQuota, } from './projection.js';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { quotaCodec } from './codec.js';
|
|
2
|
-
export { formatQuota, NO_LIMITS_REPORTED, NO_QUOTA_READING, QUOTA_STALE_AFTER_MS, quotaTextParts, quotaWindowName, } from './format.js';
|
|
3
|
-
export { ALL_SCOPE, DEFAULT_REQUIRED_LABELS, emptyQuotaMap, isQuotaMap, } from './map.js';
|
|
2
|
+
export { formatQuota, isQuotaStale, NO_LIMITS_REPORTED, NO_QUOTA_READING, QUOTA_STALE_AFTER_MS, quotaTextParts, quotaWindowName, quotaWindowParts, } from './format.js';
|
|
3
|
+
export { ALL_SCOPE, DEFAULT_REQUIRED_LABELS, decodeQuotaMap, emptyQuotaMap, isQuotaMap, } from './map.js';
|
|
4
4
|
export { isQuotaObservation, mergeQuotaObservation, QuotaCodecError, } from './merge.js';
|
|
5
5
|
export { budgetExhaustedResetAt, futureResetAt, projectQuota, readsExhausted, } from './projection.js';
|
|
@@ -44,16 +44,27 @@ export interface CreditBudgetCleared {
|
|
|
44
44
|
checkedAt: number;
|
|
45
45
|
}
|
|
46
46
|
export type CreditBudgetEntry = CreditBudgetReading | CreditBudgetCleared;
|
|
47
|
+
export interface QuotaCounter {
|
|
48
|
+
value: number;
|
|
49
|
+
checkedAt: number;
|
|
50
|
+
}
|
|
47
51
|
export interface QuotaMap {
|
|
48
52
|
limits: QuotaEntry[];
|
|
49
53
|
/** Absent means no observation has reported on the budget yet. */
|
|
50
54
|
budget?: CreditBudgetEntry;
|
|
55
|
+
/** Provider-named counts, each with its own observation time. */
|
|
56
|
+
counters?: Record<string, QuotaCounter>;
|
|
51
57
|
}
|
|
52
58
|
export declare function emptyQuotaMap(): QuotaMap;
|
|
53
59
|
/** The time an entry speaks for: its reading, retirement or absence time. */
|
|
54
60
|
export declare function entryTime(entry: QuotaEntry): number;
|
|
55
61
|
export declare function limitKey(scope: string, label: string): string;
|
|
56
62
|
export declare function isCreditBudgetEntry(value: unknown): value is CreditBudgetEntry;
|
|
63
|
+
export declare function isQuotaCounters(value: unknown): value is Record<string, QuotaCounter>;
|
|
64
|
+
/** Keep usable counters without letting one malformed count hide a quota map. */
|
|
65
|
+
export declare function readQuotaCounters(value: unknown): Record<string, QuotaCounter> | undefined;
|
|
66
|
+
/** Decode persisted quota, dropping malformed counters but not limit evidence. */
|
|
67
|
+
export declare function decodeQuotaMap(value: unknown): QuotaMap | undefined;
|
|
57
68
|
/**
|
|
58
69
|
* True when `value` is a well-formed quota map. Unrecognised top-level keys
|
|
59
70
|
* are tolerated (and preserved by merge) so a newer writer's additions
|