paseo-smart-session 1.2.1

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.
@@ -0,0 +1,115 @@
1
+ /**
2
+ * When a context is too full — which depends on how big the window is.
3
+ *
4
+ * A percentage of the window is the wrong unit on its own. What degrades recall and
5
+ * what costs money is the *absolute* size of the prefix re-read every turn, so 30%
6
+ * of a million-token window (300k) is a heavier context than 85% of a 200k one
7
+ * (170k). A single percentage band would either nag a small session or let a large
8
+ * one drift for hours.
9
+ *
10
+ * So there are two profiles. A small window is allowed to fill up, because its
11
+ * ceiling arrives before its context gets unwieldy. A large window is asked to
12
+ * compact long before the ceiling, because the ceiling was never the constraint.
13
+ */
14
+
15
+ export type Band = "notice" | "closing" | "compact";
16
+
17
+ export interface ThresholdProfile {
18
+ /** Percentage of the window at which to first mention it. */
19
+ readonly notice: number;
20
+ /** Percentage at which to stop opening new work. */
21
+ readonly closing: number;
22
+ /** Percentage at which to recommend compacting. */
23
+ readonly compact: number;
24
+ }
25
+
26
+ export interface Thresholds {
27
+ /** Windows at least this large use the `large` profile. */
28
+ readonly largeWindowFrom: number;
29
+ readonly large: ThresholdProfile;
30
+ readonly small: ThresholdProfile;
31
+ }
32
+
33
+ /**
34
+ * Defaults.
35
+ *
36
+ * The `large` numbers are deliberately early: 30% of a million tokens is 300k, and
37
+ * a 300k prefix is already past the point where a session is sharp or cheap.
38
+ * The `small` numbers let a 200k session use most of its window, because 85% of it
39
+ * is only 170k.
40
+ *
41
+ * Windows are 200k or 1M in practice, so 400k separates them with room to spare.
42
+ */
43
+ export const DEFAULT_THRESHOLDS: Thresholds = {
44
+ largeWindowFrom: 400_000,
45
+ large: { notice: 15, closing: 22, compact: 30 },
46
+ small: { notice: 60, closing: 75, compact: 85 },
47
+ };
48
+
49
+ export function profileFor(maxTokens: number, thresholds: Thresholds = DEFAULT_THRESHOLDS): ThresholdProfile {
50
+ return maxTokens >= thresholds.largeWindowFrom ? thresholds.large : thresholds.small;
51
+ }
52
+
53
+ /** The highest band a given occupancy has reached, or null for "nothing to say". */
54
+ export function bandFor(pct: number, profile: ThresholdProfile): Band | null {
55
+ if (pct >= profile.compact) return "compact";
56
+ if (pct >= profile.closing) return "closing";
57
+ if (pct >= profile.notice) return "notice";
58
+ return null;
59
+ }
60
+
61
+ /** Every band at or below the current occupancy, so each fires exactly once. */
62
+ export function bandsReached(pct: number, profile: ThresholdProfile): Band[] {
63
+ const reached: Band[] = [];
64
+ if (pct >= profile.notice) reached.push("notice");
65
+ if (pct >= profile.closing) reached.push("closing");
66
+ if (pct >= profile.compact) reached.push("compact");
67
+ return reached;
68
+ }
69
+
70
+ /** Clamps a user-supplied profile into something monotonic and in range. */
71
+ export function normalizeProfile(raw: Partial<ThresholdProfile> | undefined, fallback: ThresholdProfile): ThresholdProfile {
72
+ const clamp = (value: unknown, dflt: number): number =>
73
+ typeof value === "number" && Number.isFinite(value) ? Math.min(99, Math.max(1, value)) : dflt;
74
+ // `compact` is the user-facing setting, so it is authoritative. In particular,
75
+ // asking for 25% must not silently become the old closing threshold because the
76
+ // other two bands happened to be higher. Pull those advisory bands down instead.
77
+ const compact = clamp(raw?.compact, fallback.compact);
78
+ const closing = Math.min(compact, clamp(raw?.closing, fallback.closing));
79
+ const notice = Math.min(closing, clamp(raw?.notice, fallback.notice));
80
+ return { notice, closing, compact };
81
+ }
82
+
83
+ /**
84
+ * Changes the compact point while keeping useful space between its earlier bands.
85
+ *
86
+ * Lowering an 85% profile straight to 25% would otherwise make notice, closing and
87
+ * compact all fire together. Scale the earlier bands with it when necessary;
88
+ * raising it leaves the existing advance notice intact.
89
+ */
90
+ export function withCompactThreshold(profile: ThresholdProfile, value: number): ThresholdProfile {
91
+ const compact = Number.isFinite(value) ? Math.round(Math.min(99, Math.max(1, value))) : profile.compact;
92
+ if (compact >= profile.closing) return { ...profile, compact };
93
+
94
+ const scale = compact / profile.compact;
95
+ return normalizeProfile(
96
+ {
97
+ notice: Math.max(1, Math.round(profile.notice * scale)),
98
+ closing: Math.max(1, Math.round(profile.closing * scale)),
99
+ compact,
100
+ },
101
+ profile,
102
+ );
103
+ }
104
+
105
+ export function normalizeThresholds(raw: Partial<Thresholds> | undefined): Thresholds {
106
+ const largeWindowFrom =
107
+ typeof raw?.largeWindowFrom === "number" && raw.largeWindowFrom > 0
108
+ ? raw.largeWindowFrom
109
+ : DEFAULT_THRESHOLDS.largeWindowFrom;
110
+ return {
111
+ largeWindowFrom,
112
+ large: normalizeProfile(raw?.large, DEFAULT_THRESHOLDS.large),
113
+ small: normalizeProfile(raw?.small, DEFAULT_THRESHOLDS.small),
114
+ };
115
+ }
@@ -0,0 +1,400 @@
1
+ /**
2
+ * Normalizing plan-usage payloads into one canonical sample.
3
+ *
4
+ * Three sources report the same limits in three shapes (see RESEARCH.md §1.2):
5
+ * Claude Code's on-disk cache, Paseo's `provider.usage.list`, and the statusline
6
+ * JSON. They disagree on the percentage scale and on which windows they name, so
7
+ * every reading enters the store through here and nowhere else.
8
+ */
9
+
10
+ /** A single limit window at a point in time. */
11
+ export interface WindowSample {
12
+ /** 0-100. */
13
+ readonly pct: number;
14
+ /** ISO instant the window rolls over, or null when the provider omits it. */
15
+ readonly resetsAt: string | null;
16
+ /** The provider's own severity label, when it offers one. */
17
+ readonly severity?: string;
18
+ /** False for a window the provider reports but is not currently counting. */
19
+ readonly active?: boolean;
20
+ /** Human label for a scoped window ("Fable", "Opus"), when scoped. */
21
+ readonly scope?: string;
22
+ }
23
+
24
+ /** Extra-usage credits: real money already spent beyond the plan. */
25
+ export interface CreditsSample {
26
+ readonly usedMinor: number;
27
+ readonly limitMinor: number;
28
+ readonly exponent: number;
29
+ readonly currency: string;
30
+ readonly pct: number;
31
+ readonly enabled: boolean;
32
+ }
33
+
34
+ export type SampleSource = "upstream" | "claudejson" | "paseo" | "statusline";
35
+
36
+ export interface UsageSample {
37
+ /** When we observed it. */
38
+ readonly at: string;
39
+ /** When the provider produced it, when the source says so. Older than `at` by design. */
40
+ readonly fetchedAt: string | null;
41
+ readonly src: SampleSource;
42
+ readonly account: string | null;
43
+ readonly windows: Readonly<Record<string, WindowSample>>;
44
+ readonly credits: CreditsSample | null;
45
+ }
46
+
47
+ /**
48
+ * Canonical window ids.
49
+ *
50
+ * The `limits[]` array and the top-level keys name the same two windows
51
+ * differently ("session" vs "five_hour", "weekly_all" vs "seven_day"). Folding
52
+ * them here keeps a block's history continuous across a payload shape change.
53
+ */
54
+ const WINDOW_ALIASES: Readonly<Record<string, string>> = {
55
+ // Claude Code's `limits[]` array names the 5-hour window "session".
56
+ session: "five_hour",
57
+ // …and the plan week "weekly_all", which Paseo in turn republishes as "weekly".
58
+ weekly_all: "seven_day",
59
+ weekly: "seven_day",
60
+ };
61
+
62
+ /**
63
+ * A scoped weekly cap, named two different ways by two sources.
64
+ *
65
+ * Claude Code reports `kind: "weekly_scoped"` plus a `scope` object; Paseo flattens
66
+ * the same window into an id like `weekly_model_opus`. Left alone, one cap becomes
67
+ * two series and neither shows the real number — which matters most for exactly the
68
+ * window that bites first, the per-model weekly cap.
69
+ */
70
+ const PASEO_SCOPED = /^weekly_(model|surface)_(.+)$/;
71
+
72
+ export function canonicalWindowId(raw: string, scope?: WindowScope | null): string {
73
+ const flattened = PASEO_SCOPED.exec(raw);
74
+ if (flattened !== null) return `weekly_${flattened[1]}:${flattened[2]!.toLowerCase()}`;
75
+
76
+ const base = WINDOW_ALIASES[raw] ?? raw;
77
+ if (scope === undefined || scope === null) return base;
78
+ // Lowercased because one source spells it "Fable" and the other "fable".
79
+ return `weekly_${scope.dimension}:${scope.label.toLowerCase()}`;
80
+ }
81
+
82
+ /** Which axis a scoped window is scoped on, and to what. */
83
+ export interface WindowScope {
84
+ readonly dimension: "model" | "surface";
85
+ readonly label: string;
86
+ }
87
+
88
+ /**
89
+ * One percentage convention.
90
+ *
91
+ * `~/.claude.json` reports 26 for 26%; Claude Code's statusline builder multiplies
92
+ * its own reading by 100 before display, so that path carries 0.26. A value can be
93
+ * ambiguous only in [0, 1], where "1" means 1% far more often than 100% — a full
94
+ * window reads as 100 in every source we have seen. So: scale only what a source
95
+ * declares to be fractional, never guess per value.
96
+ */
97
+ export function toPercent(value: number, scale: "percent" | "fraction"): number {
98
+ if (!Number.isFinite(value)) return 0;
99
+ const pct = scale === "fraction" ? value * 100 : value;
100
+ return Math.min(100, Math.max(0, pct));
101
+ }
102
+
103
+ function instant(value: unknown): string | null {
104
+ if (typeof value !== "string" || value === "") return null;
105
+ const ms = Date.parse(value);
106
+ if (Number.isNaN(ms)) return null;
107
+ return new Date(ms).toISOString();
108
+ }
109
+
110
+ /**
111
+ * The scope of a `limits[]` entry.
112
+ *
113
+ * Both axes are read, and the dimension is kept: a model named "x" and a surface
114
+ * named "x" are different caps and must not share an id.
115
+ */
116
+ function readScope(scope: unknown): WindowScope | null {
117
+ if (typeof scope !== "object" || scope === null) return null;
118
+ const record = scope as Record<string, { display_name?: unknown; id?: unknown } | undefined>;
119
+ for (const dimension of ["model", "surface"] as const) {
120
+ const candidate = record[dimension];
121
+ const name = candidate?.display_name ?? candidate?.id;
122
+ if (typeof name === "string" && name !== "") return { dimension, label: name };
123
+ }
124
+ return null;
125
+ }
126
+
127
+ /**
128
+ * The `/api/oauth/usage` body, fetched directly.
129
+ *
130
+ * The authoritative source: no cache in front of it, so `fetchedAt` is genuinely
131
+ * now. Same shape as the payload Claude Code caches, minus the wrapper.
132
+ */
133
+ export function fromUpstream(raw: unknown, observedAt = new Date()): UsageSample | null {
134
+ const sample = fromClaudeJson({ fetchedAtMs: observedAt.getTime(), utilization: raw }, observedAt);
135
+ return sample === null ? null : { ...sample, src: "upstream" };
136
+ }
137
+
138
+ /**
139
+ * `~/.claude.json` -> `cachedUsageUtilization`, and the `/api/oauth/usage` body it
140
+ * caches. Both the named top-level windows and the self-describing `limits[]` are
141
+ * read; `limits[]` wins on conflict because it carries severity and an active flag.
142
+ */
143
+ export function fromClaudeJson(raw: unknown, observedAt = new Date()): UsageSample | null {
144
+ if (typeof raw !== "object" || raw === null) return null;
145
+ const record = raw as Record<string, unknown>;
146
+ const utilization = record.utilization;
147
+ if (typeof utilization !== "object" || utilization === null) return null;
148
+ const util = utilization as Record<string, unknown>;
149
+
150
+ const windows: Record<string, WindowSample> = {};
151
+
152
+ for (const [key, value] of Object.entries(util)) {
153
+ if (key === "limits" || key === "extra_usage" || key === "spend") continue;
154
+ if (typeof value !== "object" || value === null) continue;
155
+ const window = value as Record<string, unknown>;
156
+ if (typeof window.utilization !== "number") continue;
157
+ windows[canonicalWindowId(key)] = {
158
+ pct: toPercent(window.utilization, "percent"),
159
+ resetsAt: instant(window.resets_at),
160
+ };
161
+ }
162
+
163
+ const limits = util.limits;
164
+ if (Array.isArray(limits)) {
165
+ for (const entry of limits) {
166
+ if (typeof entry !== "object" || entry === null) continue;
167
+ const limit = entry as Record<string, unknown>;
168
+ if (typeof limit.kind !== "string" || typeof limit.percent !== "number") continue;
169
+ const scope = readScope(limit.scope);
170
+ windows[canonicalWindowId(limit.kind, scope)] = {
171
+ pct: toPercent(limit.percent, "percent"),
172
+ resetsAt: instant(limit.resets_at),
173
+ ...(typeof limit.severity === "string" ? { severity: limit.severity } : {}),
174
+ ...(typeof limit.is_active === "boolean" ? { active: limit.is_active } : {}),
175
+ ...(scope !== null ? { scope: scope.label } : {}),
176
+ };
177
+ }
178
+ }
179
+
180
+ if (Object.keys(windows).length === 0) return null;
181
+
182
+ const fetchedAtMs = record.fetchedAtMs;
183
+ return {
184
+ at: observedAt.toISOString(),
185
+ fetchedAt: typeof fetchedAtMs === "number" ? new Date(fetchedAtMs).toISOString() : null,
186
+ src: "claudejson",
187
+ account: typeof record.accountUuid === "string" ? record.accountUuid : null,
188
+ windows,
189
+ credits: creditsFromSpend(util.spend),
190
+ };
191
+ }
192
+
193
+ /**
194
+ * The `spend` section, which reports extra-usage credits in minor units. This is
195
+ * the only figure in any payload denominated in money already spent rather than
196
+ * in percent of an opaque allowance, so it is worth carrying separately.
197
+ */
198
+ function creditsFromSpend(raw: unknown): CreditsSample | null {
199
+ if (typeof raw !== "object" || raw === null) return null;
200
+ const spend = raw as Record<string, unknown>;
201
+ const used = spend.used as Record<string, unknown> | undefined;
202
+ const limit = spend.limit as Record<string, unknown> | undefined;
203
+ if (typeof used?.amount_minor !== "number") return null;
204
+ return {
205
+ usedMinor: used.amount_minor,
206
+ limitMinor: typeof limit?.amount_minor === "number" ? limit.amount_minor : 0,
207
+ exponent: typeof used.exponent === "number" ? used.exponent : 2,
208
+ currency: typeof used.currency === "string" ? used.currency : "USD",
209
+ pct: typeof spend.percent === "number" ? toPercent(spend.percent, "percent") : 0,
210
+ enabled: spend.enabled === true,
211
+ };
212
+ }
213
+
214
+ /**
215
+ * Paseo's `provider.usage.list`, which has already parsed the upstream body into
216
+ * `providers[].windows[]`. Its percentages arrive under one of several field names
217
+ * depending on daemon version, so all the known spellings are tried.
218
+ *
219
+ * This is the authoritative source: the daemon fetches upstream itself on a 5 minute
220
+ * TTL and stamps the response with the instant it actually fetched, so a reading is
221
+ * never older than five minutes and always says how old it is.
222
+ */
223
+ export function fromPaseoProviderUsage(
224
+ raw: unknown,
225
+ providerId = "claude",
226
+ observedAt = new Date(),
227
+ ): UsageSample | null {
228
+ if (typeof raw !== "object" || raw === null) return null;
229
+ const providers = (raw as { providers?: unknown }).providers;
230
+ if (!Array.isArray(providers)) return null;
231
+ const entry = providers.find(
232
+ (candidate) =>
233
+ typeof candidate === "object" &&
234
+ candidate !== null &&
235
+ (candidate as { providerId?: unknown }).providerId === providerId,
236
+ ) as { windows?: unknown } | undefined;
237
+ if (entry === undefined || !Array.isArray(entry.windows)) return null;
238
+
239
+ const windows: Record<string, WindowSample> = {};
240
+ for (const candidate of entry.windows) {
241
+ if (typeof candidate !== "object" || candidate === null) continue;
242
+ const window = candidate as Record<string, unknown>;
243
+ if (typeof window.id !== "string") continue;
244
+ const percent = readPercent(window);
245
+ if (percent === null) continue;
246
+ const flattened = PASEO_SCOPED.exec(window.id);
247
+ windows[canonicalWindowId(window.id)] = {
248
+ pct: percent,
249
+ resetsAt: instant(window.resetsAt ?? window.resets_at),
250
+ ...(typeof window.tone === "string" ? { severity: window.tone } : {}),
251
+ ...(flattened !== null ? { scope: flattened[2]! } : {}),
252
+ };
253
+ }
254
+ if (Object.keys(windows).length === 0) return null;
255
+
256
+ const fetchedAt = instant((raw as { fetchedAt?: unknown }).fetchedAt);
257
+ return {
258
+ at: observedAt.toISOString(),
259
+ fetchedAt,
260
+ src: "paseo",
261
+ account: null,
262
+ windows,
263
+ credits: null,
264
+ };
265
+ }
266
+
267
+ /** Percent under any of the spellings a daemon version might use. */
268
+ function readPercent(window: Record<string, unknown>): number | null {
269
+ if (typeof window.usedPct === "number") return toPercent(window.usedPct, "percent");
270
+ if (typeof window.used_percentage === "number") return toPercent(window.used_percentage, "percent");
271
+ if (typeof window.percent === "number") return toPercent(window.percent, "percent");
272
+ // `utilization` is the upstream field name and the one place a fraction shows up.
273
+ if (typeof window.utilization === "number") {
274
+ return toPercent(window.utilization, window.utilization <= 1 ? "fraction" : "percent");
275
+ }
276
+ return null;
277
+ }
278
+
279
+ /** The statusline payload's `rate_limits`, whose percentages are already 0-100. */
280
+ export function fromStatusline(raw: unknown, observedAt = new Date()): UsageSample | null {
281
+ if (typeof raw !== "object" || raw === null) return null;
282
+ const rateLimits = (raw as { rate_limits?: unknown }).rate_limits;
283
+ if (typeof rateLimits !== "object" || rateLimits === null) return null;
284
+
285
+ const windows: Record<string, WindowSample> = {};
286
+ for (const [key, value] of Object.entries(rateLimits as Record<string, unknown>)) {
287
+ if (typeof value !== "object" || value === null) continue;
288
+ const window = value as Record<string, unknown>;
289
+ if (typeof window.used_percentage !== "number") continue;
290
+ windows[canonicalWindowId(key)] = {
291
+ pct: toPercent(window.used_percentage, "percent"),
292
+ // The statusline reports resets_at as epoch seconds, not ISO.
293
+ resetsAt:
294
+ typeof window.resets_at === "number"
295
+ ? new Date(window.resets_at * 1000).toISOString()
296
+ : instant(window.resets_at),
297
+ };
298
+ }
299
+ if (Object.keys(windows).length === 0) return null;
300
+
301
+ return {
302
+ at: observedAt.toISOString(),
303
+ fetchedAt: null,
304
+ src: "statusline",
305
+ account: null,
306
+ windows,
307
+ credits: null,
308
+ };
309
+ }
310
+
311
+ /**
312
+ * When the reading was true, as opposed to when we noticed it.
313
+ *
314
+ * `~/.claude.json` is a cache that only a running Claude Code refreshes, and it is
315
+ * routinely hours behind — observed 14h stale on this machine while a Paseo-driven
316
+ * session was active. Plotting such a reading at its observation time would draw a
317
+ * long-finished window across the present, so every analysis keys off this and
318
+ * never off `at`.
319
+ */
320
+ export function effectiveAt(sample: UsageSample): string {
321
+ return sample.fetchedAt ?? sample.at;
322
+ }
323
+
324
+ /** Whether a reading was already this old when we saw it. */
325
+ export function isStale(sample: UsageSample, toleranceMs = 5 * 60 * 1000): boolean {
326
+ if (sample.fetchedAt === null) return false;
327
+ return Date.parse(sample.at) - Date.parse(sample.fetchedAt) > toleranceMs;
328
+ }
329
+
330
+ /**
331
+ * Whether a new reading is worth a line in the store.
332
+ *
333
+ * Samples arrive far faster than they change: a 60s poll over a quiet hour is 60
334
+ * identical readings. Only a changed percentage, a rolled window, an appeared or
335
+ * vanished window, or a credit movement is recorded — plus a heartbeat, so that a
336
+ * long flat stretch stays distinguishable from a recorder that was not running.
337
+ */
338
+ export function isWorthRecording(
339
+ previous: UsageSample | null,
340
+ next: UsageSample,
341
+ heartbeatMs = 10 * 60 * 1000,
342
+ ): boolean {
343
+ if (previous === null) return true;
344
+ if (Date.parse(next.at) - Date.parse(previous.at) >= heartbeatMs) return true;
345
+ // A cache re-read that carries the same upstream timestamp is the same reading.
346
+ if (previous.src === next.src && next.fetchedAt !== null && previous.fetchedAt === next.fetchedAt) {
347
+ return false;
348
+ }
349
+
350
+ const ids = new Set([...Object.keys(previous.windows), ...Object.keys(next.windows)]);
351
+ for (const id of ids) {
352
+ const before = previous.windows[id];
353
+ const after = next.windows[id];
354
+ if (before === undefined || after === undefined) return true;
355
+ if (before.pct !== after.pct) return true;
356
+ if (before.resetsAt !== after.resetsAt) return true;
357
+ }
358
+
359
+ if ((previous.credits?.usedMinor ?? null) !== (next.credits?.usedMinor ?? null)) return true;
360
+ return false;
361
+ }
362
+
363
+
364
+ /**
365
+ * Whether a reading may enter the series at all.
366
+ *
367
+ * Correctness before resolution. Two of the three sources are caches that can hand
368
+ * back something hours old — `~/.claude.json` only refreshes when a Claude Code
369
+ * session refreshes it, which in practice means when someone runs `/usage`. A stale
370
+ * reading plotted as current would understate a window that has since filled, which
371
+ * is the exact failure this whole store exists to avoid.
372
+ *
373
+ * So a reading is refused when it carries no new information (it is older than what
374
+ * we already hold), or when it contradicts a hard invariant of the window itself.
375
+ */
376
+ export function acceptReading(
377
+ newestAccepted: UsageSample | null,
378
+ next: UsageSample,
379
+ ): { readonly ok: boolean; readonly reason?: string } {
380
+ if (newestAccepted === null) return { ok: true };
381
+
382
+ const known = Date.parse(effectiveAt(newestAccepted));
383
+ const incoming = Date.parse(effectiveAt(next));
384
+ if (!Number.isFinite(incoming)) return { ok: false, reason: "unparseable timestamp" };
385
+ if (incoming < known) return { ok: false, reason: "older than the newest reading held" };
386
+
387
+ for (const [id, window] of Object.entries(next.windows)) {
388
+ const previous = newestAccepted.windows[id];
389
+ if (previous === undefined) continue;
390
+ // Same window instance: a limit window only ever fills until it rolls over, so a
391
+ // percentage that went backwards is a stale or incoherent reading, not progress.
392
+ const sameInstance =
393
+ previous.resetsAt !== null && window.resetsAt !== null && previous.resetsAt === window.resetsAt;
394
+ if (sameInstance && window.pct < previous.pct) {
395
+ return { ok: false, reason: `${id} went backwards within one window` };
396
+ }
397
+ }
398
+
399
+ return { ok: true };
400
+ }