/** * Quota snapshot store + acquisition (Plan B — PB-T1). * * Stores **raw provider-category snapshots** at `~/.scoutline/state.json`, * separate from `config.json` (Plan A owns `config.json`; this module * owns the quota namespace of `state.json` exclusively). Versioned, * atomic writes reuse Plan A's {@link atomicReplaceFile} primitive. * * Two timestamps per provider (review item 13): * - `observedAt` — last provider ground-truth (refresh/harvest). * PB-T1 advances this. Staleness/authority uses this clock. * - `locallyUpdatedAt` — last local consumption estimate (PB-T2). * PB-T1 defines the field but does NOT advance it; PB-T2 does. * * The schema stores the LIVE `QuotaCategory[]` shape verbatim — the * normalized categories from `ProviderQuotaSuccess`. PB-T3 maps them * to capabilities; PB-T1 does not derive a score. * * Boundary rules: * - Imports the atomic primitive from `config-store.js`, the quota * contract types from `capabilities/quota.js`, and provider * identity types. No provider transport, no command presentation. * - Fail-open on read: quota state is observational. A corrupt or * version-mismatched `state.json` yields an empty state + warning, * NEVER a thrown error. Unlike `config.json` (which gates * credentials), `state.json` must never block the CLI. * * USAGE ASSUMPTION — "Usage endpoints are free": * The refresh path assumes the per-provider quota/usage endpoints * (Z.AI monitor, Tavily `/usage`, Firecrawl `/team/credit-usage`, * MiniMax `/remains`) do NOT consume billable credits. This was * characterized against provider docs at implementation time. If a * provider changes its usage endpoint to consume credits, the * refresh becomes a cost source — re-verify against provider docs * before relying on the periodic refresh. */ import type { QuotaCategory } from "../capabilities/quota.js"; import type { ProviderId } from "../providers/types.js"; import { type AtomicReplaceOptions, type ConfigRootEnvironment } from "./config-store.js"; export declare const QUOTA_STATE_VERSION: 1; /** * A single provider's raw quota snapshot. The `categories` array is the * verbatim `ProviderQuotaSuccess.categories` payload — PB-T3 maps these * to capabilities; PB-T1 does not re-derive. * * `observedAt` advances on refresh/harvest (provider ground-truth). * `locallyUpdatedAt` advances on consumption (PB-T2 local decrement). * A stale `observedAt` with a recent `locallyUpdatedAt` is still * non-authoritative — local estimates never reset the ground-truth * clock. */ /** * Local finite decrements not yet absorbed into provider `used` * (GitHub #41). Keys are category names; values are accumulated * exact/estimate amounts since the last harvest. Absent or empty * means the displayed `categories` match the last provider payload. */ export type PendingDecrements = Readonly>; export interface ProviderQuotaSnapshot { readonly observedAt: number; readonly locallyUpdatedAt?: number; readonly categories: readonly QuotaCategory[]; readonly decrementedSinceObserved?: PendingDecrements; } /** * The on-disk state file shape. Owns the quota namespace exclusively; * `config.json` (Plan A) is a separate file. `version` gates schema * evolution; a mismatched version triggers fail-open (not a throw). */ export interface QuotaState { readonly version: typeof QUOTA_STATE_VERSION; readonly quota: Partial>; } /** * Resolve the absolute path to `state.json`. Defaults to * `/state.json` where `` is * `resolveConfigRoot()` (`~/.scoutline/` by default; overridable via * `SCOUTLINE_CONFIG_DIR`). This reuses Plan A's dedicated root — the * same root `config.json` lives in — so both files share the 0700 * directory permissions `atomicReplaceFile` enforces. */ export declare function stateFilePath(root?: string): string; export interface QuotaStoreOptions { readonly filePath?: string; /** * #132 — env view the state path resolves from when `filePath` is * omitted. When supplied, `stateFilePath()` resolves through the PURE * resolver over this view (mirroring the usage-ledger sink), so a * caller threading its invocation env (`main()` passes `deps.env`) * sees that env's config root instead of ambient `process.env`. * When omitted, the ambient guarded resolver (`resolveConfigRoot`, * the #119 fence) is kept verbatim. */ readonly env?: ConfigRootEnvironment; readonly now?: () => number; readonly onWarning?: (warning: QuotaStoreWarning) => void; readonly atomic?: AtomicReplaceOptions; } export interface QuotaStoreWarning { readonly code: "STATE_CORRUPT" | "STATE_VERSION_MISMATCH" | "STATE_READ_ERROR" | "STATE_WRITE_ERROR"; readonly message: string; } /** * Apply a consumption write. Missing snapshots become an `observedAt: 0` * scaffold so pre-harvest decrements persist until the first harvest. */ export declare function applyWriteConsumption(prior: ProviderQuotaSnapshot | undefined, adjustment: ConsumptionAdjustment, at: number): ProviderQuotaSnapshot; /** * Merge a fresh provider harvest with unacknowledged local decrements. * Provider `used` growth since the last harvest absorbs pending amounts * (no double-count). Leftover pending is re-applied onto the fresh * categories so a lagging usage endpoint does not clobber local estimates. */ export declare function applyWriteObserved(prior: ProviderQuotaSnapshot | undefined, snapshot: ProviderQuotaSnapshot): ProviderQuotaSnapshot; /** * Quota unit, lifted from {@link QuotaCategory} for store-internal use * without importing the full capability contract. PB-T2's consumption * adjustment matches both `category` name AND `unit` against the * snapshot to avoid cross-unit drift (e.g. applying a `requests` * decrement against a `credits` category). */ export type QuotaUnit = "requests" | "tokens" | "credits" | "USD"; /** * The amount a single billable attempt consumed. PB-T2's contract: * never fake-precise. The store adjusts numeric estimates only when a * matching category exposes a count set; an `unknown` amount still * advances `locallyUpdatedAt` (so the snapshot reflects that *some* * consumption happened) but never mutates numeric fields. */ export type ConsumptionAmount = { readonly kind: "exact"; readonly value: number; } | { readonly kind: "estimate"; readonly value: number; } | { readonly kind: "unknown"; }; /** * Adjustment payload for {@link QuotaStore.writeConsumption} (PB-T2). * The store matches `category` + `unit` against the snapshot's * categories; an absent match means the category isn't tracked, and * only `locallyUpdatedAt` advances. */ export interface ConsumptionAdjustment { readonly category?: string; readonly unit?: QuotaUnit; readonly amount: ConsumptionAmount; } /** * Injectable quota snapshot store. Production wires * {@link createDefaultQuotaStore} (real atomic read-merge-write against * `~/.scoutline/state.json`); tests inject in-memory doubles so store * assertions never touch real config-root I/O. * * Contract: * - {@link read} is fail-open: absent/corrupt/version-mismatched * files yield an empty state + warning, never a throw. * - {@link writeObserved} performs an atomic read-merge-write: other * providers' snapshots are preserved. `observedAt` advances to the * harvest clock. Unacknowledged local decrements * (`decrementedSinceObserved`) are reconciled against provider * `used` growth and any leftover is re-applied onto the fresh * categories. `locallyUpdatedAt` is preserved from the existing * snapshot (PB-T2 advances it, not PB-T1). * - {@link writeConsumption} (PB-T2) advances `locallyUpdatedAt` and * adjusts the matching category's `current` count set when a * finite decrement is supplied. `observedAt` is preserved (ground * truth never moves on a local estimate). A missing snapshot is * scaffolded (`observedAt: 0`) so pre-harvest decrements persist; * a missing category still only advances `locallyUpdatedAt`. * - {@link clear} removes a single provider's snapshot (or all when * no ID is given). Used by future reset/diagnostic commands. */ export interface QuotaStore { read(): Promise; writeObserved(providerId: ProviderId, snapshot: ProviderQuotaSnapshot): Promise; writeConsumption(providerId: ProviderId, adjustment: ConsumptionAdjustment, at: number): Promise; clear(providerId?: ProviderId): Promise; } /** * Production {@link QuotaStore}. Reads and writes flow through * {@link atomicReplaceFile} (Plan A T1) so the write is crash-safe and * 0600-permissioned. The read-merge-write is serialized within the * process via {@link withFileLock}; cross-process concurrency is * last-write-wins. */ export declare function createDefaultQuotaStore(options?: QuotaStoreOptions): QuotaStore; /** * Build an in-memory {@link QuotaStore} for hermetic tests. No file * I/O; the state lives in a closure. Writes are synchronous-ish (the * returned promises resolve on the next microtask) so tests can assert * immediately after `await`. */ export declare function createInMemoryQuotaStore(initial?: QuotaState, options?: { onWarning?: (warning: QuotaStoreWarning) => void; }): QuotaStore & { readonly state: QuotaState; }; /** * Write a single provider's observed snapshot to the production default * store (`~/.scoutline/state.json`). Convenience wrapper for callers * (the Brave passive harvest) that do not own a long-lived * {@link QuotaStore} instance — it constructs a default store per call * and delegates to {@link QuotaStore.writeObserved}. * * The per-call construction is cheap: `createDefaultQuotaStore` resolves * the file path once and returns a thin object; no I/O happens until * `writeObserved` runs. The per-file mutex inside the default store * serializes concurrent writes within the process regardless of how * many store instances exist (the mutex is keyed on the resolved file * path). * * Fail-open: a write error is swallowed by the default store's warning * sink (stderr notice); the returned promise never rejects. This keeps * the Brave harvest best-effort — a store failure can never convert a * search success into a fallback. */ export declare function writeQuotaSnapshot(providerId: ProviderId, snapshot: ProviderQuotaSnapshot, options?: QuotaStoreOptions): Promise; /** * The default per-provider refresh threshold. Tavily's documented * 10 calls / 10 minutes / key limit is the floor; this threshold * ensures the after-command due-refresh never exceeds one call per * provider per 10 minutes. Explicit `quota`/`doctor` refreshes bypass * this (the user asked for fresh data). */ export declare const DEFAULT_QUOTA_STALE_THRESHOLD_MS: number; /** * Selection-only freshness horizon for KNOWN_EXHAUSTED demotion * (#97): a 0% reading on a provider's capability-mapped category * demotes that provider below the unknown tier in * {@link "../lib/quota-mapping.js".rankProvidersForCapability} only * while `now - observedAt` is within this horizon. Snapshots older * than the horizon score exactly as they did before #97. * * Deliberately NOT the 10-minute {@link DEFAULT_QUOTA_STALE_THRESHOLD_MS} * command-cadence gate, on two grounds: (a) the after-command * due-refresh is command-coupled and best-effort, so snapshot * freshness at selection time is not oracle-grade; (b) gating * demotion on the 10-minute threshold would tie exhaustion-trust to * refresh cadence — any >10-minute pause between commands would lapse * the demotion and float a still-exhausted provider back to the top. * The 24h horizon decouples exhaustion-trust from the command-cadence * gate; the value is insensitive anywhere in the hours-to-days band, * so retuning is a one-constant change. */ export declare const QUOTA_EXHAUSTION_DEMOTION_HORIZON_MS: number; /** * A snapshot is stale when `observedAt` is older than the threshold * relative to `now`. A missing snapshot (undefined) is always stale. */ export declare function isQuotaSnapshotStale(snapshot: ProviderQuotaSnapshot | undefined, now: number, thresholdMs?: number): boolean; /** * The shape this module needs from a Provider Descriptor to refresh * quota. Kept as a structural subset (not the full `ProviderDescriptor`) * so the refresh coordinator stays testable without importing * transport-level types. Production passes real descriptors; tests pass * fakes. */ export interface QuotaRefreshDescriptor { readonly id: ProviderId; isConfigured(env: NodeJS.ProcessEnv): boolean; capabilities(): ReadonlySet; create(context: { readonly env: NodeJS.ProcessEnv; }): { readonly quota?: { invoke(): Promise<{ categories: readonly QuotaCategory[]; }>; }; }; } export interface QuotaRefreshOptions { readonly descriptors: readonly QuotaRefreshDescriptor[]; readonly env: NodeJS.ProcessEnv; readonly store: QuotaStore; readonly now?: () => number; readonly thresholdMs?: number; /** * When `true`, ignore the staleness threshold and refresh every * configured provider with a quota capability. Used by the explicit * `quota`/`doctor` trigger (the user asked for fresh data). The * per-provider transport timeout + single-attempt contract still * applies; only the cadence gate is bypassed. */ readonly force?: boolean; /** * Best-effort per-provider error sink. A refresh failure is isolated: * the failing provider's snapshot stays stale (or absent), the other * providers' writes proceed, and the caller's promise never rejects. * Production wires a stderr notice; tests inject a recorder. */ readonly onError?: (providerId: ProviderId, error: unknown) => void; } /** * Refresh quota snapshots for every configured provider that advertises * a `quota` capability. * * Contract: * - **Single attempt per provider.** Does NOT route through * `executeProviderOperation` — that primitive defaults quota to one * retry (`lib/execution.ts`), which violates this ticket's * single-attempt rule. Each provider's transport already carries * its own timeout (AbortController); this coordinator relies on * that and does not add an outer retry loop. * - **Bounded cadence.** When `force` is false, a provider whose * `observedAt` is within {@link DEFAULT_QUOTA_STALE_THRESHOLD_MS} * is skipped. When `force` is true (explicit `quota`/`doctor`), * the cadence gate is bypassed. * - **Parallel, isolated.** All due providers are queried in parallel * (`Promise.allSettled`-style); each failure is routed to * `onError` and never rejects the outer promise. * - **Raw categories only.** The verbatim `ProviderQuotaSuccess.categories` * array is written; PB-T3 maps them. No score is derived here. * - **`observedAt` only.** The write advances `observedAt`; * `locallyUpdatedAt` is preserved by the store (PB-T2 advances it). * * This function is the single acquisition path for the periodic * refresh. The Brave passive harvest is wired separately through the * Brave search capability's `onResponseHeaders` callback. */ export declare function refreshQuotaSnapshots(options: QuotaRefreshOptions): Promise; //# sourceMappingURL=quota-store.d.ts.map