@cortexkit/common-auth 0.2.3 → 0.2.5

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.
Files changed (44) hide show
  1. package/dist/auth-menu/accounts.d.ts +88 -0
  2. package/dist/auth-menu/accounts.js +401 -0
  3. package/dist/auth-menu/ansi.d.ts +24 -0
  4. package/dist/auth-menu/ansi.js +67 -0
  5. package/dist/auth-menu/confirm.d.ts +7 -0
  6. package/dist/auth-menu/confirm.js +21 -0
  7. package/dist/auth-menu/doctor.d.ts +52 -0
  8. package/dist/auth-menu/doctor.js +81 -0
  9. package/dist/auth-menu/index.d.ts +17 -0
  10. package/dist/auth-menu/index.js +12 -0
  11. package/dist/auth-menu/login.d.ts +47 -0
  12. package/dist/auth-menu/login.js +63 -0
  13. package/dist/auth-menu/menu.d.ts +59 -0
  14. package/dist/auth-menu/menu.js +73 -0
  15. package/dist/auth-menu/opencode-v1.d.ts +49 -0
  16. package/dist/auth-menu/opencode-v1.js +47 -0
  17. package/dist/auth-menu/select.d.ts +21 -0
  18. package/dist/auth-menu/select.js +153 -0
  19. package/dist/auth-menu/terminal.d.ts +43 -0
  20. package/dist/auth-menu/terminal.js +19 -0
  21. package/dist/cachekeep/index.d.ts +4 -0
  22. package/dist/cachekeep/index.js +2 -0
  23. package/dist/cachekeep/manager.d.ts +214 -0
  24. package/dist/cachekeep/manager.js +424 -0
  25. package/dist/cachekeep/window.d.ts +13 -0
  26. package/dist/cachekeep/window.js +33 -0
  27. package/dist/claustrum/index.d.ts +1 -0
  28. package/dist/claustrum/index.js +2 -0
  29. package/dist/commands/index.d.ts +1 -0
  30. package/dist/commands/index.js +2 -0
  31. package/dist/dump/index.d.ts +90 -0
  32. package/dist/dump/index.js +292 -0
  33. package/dist/opencode2/index.d.ts +1 -0
  34. package/dist/opencode2/index.js +2 -0
  35. package/dist/store/errors.d.ts +2 -2
  36. package/dist/store/hooks.d.ts +1 -1
  37. package/dist/store/index.d.ts +1 -1
  38. package/dist/store/mutate.d.ts +3 -2
  39. package/dist/store/mutate.js +2 -1
  40. package/dist/store/pool.d.ts +8 -1
  41. package/dist/store/pool.js +2 -1
  42. package/dist/store/rows.d.ts +28 -1
  43. package/dist/store/rows.js +93 -0
  44. package/package.json +37 -3
@@ -0,0 +1,19 @@
1
+ /** The current process's terminal, with its signals. */
2
+ export function processTerminal() {
3
+ return {
4
+ input: process.stdin,
5
+ output: process.stdout,
6
+ signals: process,
7
+ };
8
+ }
9
+ /**
10
+ * Whether keys can be read one at a time. Only the input is checked, as the
11
+ * plugins' menus always did: output piped to a file still gets the menu.
12
+ */
13
+ export function isInteractive(terminal) {
14
+ return terminal.input.isTTY === true;
15
+ }
16
+ /** Writes one line of plain output below the menu. */
17
+ export function printLine(terminal, line = '') {
18
+ terminal.output.write(`${line}\n`);
19
+ }
@@ -0,0 +1,4 @@
1
+ export type { CacheKeepAdapter, CacheKeepBackoff, CacheKeepLogger, CacheKeepManagerOptions, CacheKeepProfile, CacheKeepSendInput, CacheKeepStatus, CacheKeepTargetStatus, CacheKeepTargetView, CacheKeepTrackInput, CacheKeepTrackResult, } from './manager.js';
2
+ export { CacheKeepManager } from './manager.js';
3
+ export type { CacheKeepWindow } from './window.js';
4
+ export { isWithinCacheKeepWindow, normalizeCacheKeepWindow, } from './window.js';
@@ -0,0 +1,2 @@
1
+ export { CacheKeepManager } from './manager.js';
2
+ export { isWithinCacheKeepWindow, normalizeCacheKeepWindow, } from './window.js';
@@ -0,0 +1,214 @@
1
+ import { type CacheKeepWindow } from './window.js';
2
+ export interface CacheKeepLogger {
3
+ debug(message: string, data?: unknown): void;
4
+ warn(message: string, data?: unknown): void;
5
+ }
6
+ /**
7
+ * Per-target overrides a plugin derives from the captured request, usually
8
+ * from its model: how long the provider keeps that model's prompt cache, how
9
+ * many warms a session of that kind is worth, and how long it may sit idle.
10
+ * Evaluated once at capture time and kept on the target.
11
+ */
12
+ export interface CacheKeepProfile {
13
+ /** Provider cache lifetime for this request; defaults to the manager's `ttlMs`. */
14
+ ttlMs?: number;
15
+ /** Drop the target after this many successful warms; unlimited when absent. */
16
+ maxWarms?: number;
17
+ /** Idle bound replacing the main or subagent default for this target. */
18
+ maxIdleMs?: number;
19
+ }
20
+ /** What the plugin sees of a tracked target. */
21
+ export interface CacheKeepTargetView<M> {
22
+ sessionKey: string;
23
+ accountId: string | undefined;
24
+ bodyText: string;
25
+ isSubagent: boolean;
26
+ meta: M;
27
+ warmCount: number;
28
+ ttlMs: number;
29
+ }
30
+ export interface CacheKeepSendInput<M> {
31
+ target: CacheKeepTargetView<M>;
32
+ /** The replay body `buildBody` returned. */
33
+ body: string;
34
+ /** Aborted on the warm timeout and when the manager stops. */
35
+ signal: AbortSignal;
36
+ }
37
+ /**
38
+ * The provider half of keep-warm. The manager decides when and what to warm;
39
+ * the plugin turns a captured request into a replay, resolves the account's
40
+ * credential, sends it and reads usage back.
41
+ */
42
+ export interface CacheKeepAdapter<M> {
43
+ /** Build the replay body from the captured one. A throw backs the target off. */
44
+ buildBody(target: CacheKeepTargetView<M>): string | Promise<string>;
45
+ /**
46
+ * Resolve the target account's credential and send the replay. A throw
47
+ * (including "no credential") or a non-2xx response backs the target off.
48
+ */
49
+ send(input: CacheKeepSendInput<M>): Promise<Response>;
50
+ /** Read usage from a successful response, for the log line only. */
51
+ readUsage?(input: {
52
+ target: CacheKeepTargetView<M>;
53
+ response: Response;
54
+ text: string;
55
+ }): Record<string, unknown> | undefined;
56
+ /** Per-target TTL, warm cap and idle bound, from the captured request. */
57
+ profile?(input: {
58
+ bodyText: string;
59
+ isSubagent: boolean;
60
+ accountId: string | undefined;
61
+ meta: M;
62
+ }): CacheKeepProfile | undefined;
63
+ /**
64
+ * The account the session routes to now, asked before every warm. A
65
+ * different account than the one that served the captured request drops
66
+ * the target: its cache lives on the old account, which the session has
67
+ * left, and replaying on the new one would only write a cache nobody asked
68
+ * for. `undefined` means the plugin holds no session-to-account binding and
69
+ * the captured account stands. A throw backs the target off.
70
+ */
71
+ activeAccount?(sessionKey: string, target: CacheKeepTargetView<M>): string | undefined | Promise<string | undefined>;
72
+ }
73
+ export type CacheKeepBackoff = number | ((input: {
74
+ sessionKey: string;
75
+ failures: number;
76
+ }) => number);
77
+ export interface CacheKeepManagerOptions<M> {
78
+ adapter: CacheKeepAdapter<M>;
79
+ now: () => number;
80
+ logger?: CacheKeepLogger;
81
+ /** Default provider cache lifetime. Default 5 minutes. */
82
+ ttlMs?: number;
83
+ /** Warm this long before the cache expires. Default one tick plus 15 s. */
84
+ leadMs?: number;
85
+ /** Idle bound for main sessions, from their last real request. Default 1 h. */
86
+ maxIdleWarmMs?: number;
87
+ /** Idle bound for subagent sessions. Default 30 minutes. */
88
+ maxSubagentIdleMs?: number;
89
+ /** How often the timer runs a tick. Default 60 s. */
90
+ tickIntervalMs?: number;
91
+ /** Most sessions tracked at once. Default 32. */
92
+ maxTargets?: number;
93
+ /** Total captured body bytes (UTF-8). Default 8 MiB. */
94
+ maxBytes?: number;
95
+ /** One deadline for credential resolution and the send. Default 30 s. */
96
+ warmTimeoutMs?: number;
97
+ /** Delay after a failed warm, fixed or by consecutive failures. Default 10 minutes. */
98
+ backoffMs?: CacheKeepBackoff;
99
+ /** The configured clock window; undefined means always warm. Read on every call. */
100
+ getWindow?: () => CacheKeepWindow | undefined;
101
+ /** When true, main sessions are exempt from idle pruning. Read on every call. */
102
+ getSustain?: () => boolean;
103
+ setIntervalImpl?: typeof globalThis.setInterval;
104
+ clearIntervalImpl?: typeof globalThis.clearInterval;
105
+ }
106
+ export type CacheKeepTrackResult = {
107
+ tracked: true;
108
+ } | {
109
+ tracked: false;
110
+ reason: 'body-exceeds-max-bytes' | 'outside-window';
111
+ };
112
+ export type CacheKeepTrackInput<M> = {
113
+ sessionKey: string;
114
+ bodyText: string;
115
+ /** The account that served this request; the one a warm replays on. */
116
+ accountId?: string;
117
+ isSubagent?: boolean;
118
+ } & (undefined extends M ? {
119
+ /** Plugin data handed back to the adapter, e.g. replay headers. */ meta?: M;
120
+ } : {
121
+ /** Plugin data handed back to the adapter, e.g. replay headers. */ meta: M;
122
+ });
123
+ export interface CacheKeepTargetStatus {
124
+ sessionKey: string;
125
+ accountId: string | undefined;
126
+ isSubagent: boolean;
127
+ cacheExpiresAt: number;
128
+ lastRealRequestAt: number;
129
+ lastWarmedAt?: number;
130
+ backoffUntil?: number;
131
+ failures: number;
132
+ warmCount: number;
133
+ ttlMs: number;
134
+ bodyBytes: number;
135
+ }
136
+ export interface CacheKeepStatus {
137
+ running: boolean;
138
+ tracked: number;
139
+ totalBytes: number;
140
+ generatedAt: number;
141
+ startedAt: number | null;
142
+ ttlMs: number;
143
+ leadMs: number;
144
+ maxIdleWarmMs: number;
145
+ maxSubagentIdleMs: number;
146
+ sustain: boolean;
147
+ window?: CacheKeepWindow;
148
+ targets: CacheKeepTargetStatus[];
149
+ }
150
+ /**
151
+ * Keeps idle prompt caches warm: one target per session holding the latest
152
+ * request body, replayed just before the provider's cache would expire.
153
+ *
154
+ * Bounds, in the order they act: the clock window gates capture and warming
155
+ * (targets captured earlier survive outside it); idle caps prune a target its
156
+ * session stopped using (`sustain` lifts only the main-session cap); the
157
+ * target-count and byte caps evict the least recently touched target; a
158
+ * per-target warm cap retires short-lived sessions; and a failed warm backs
159
+ * that target off without touching the others.
160
+ */
161
+ export declare class CacheKeepManager<M = undefined> {
162
+ private readonly targets;
163
+ private readonly adapter;
164
+ private readonly now;
165
+ private readonly log;
166
+ private readonly ttlMs;
167
+ private readonly leadMs;
168
+ private readonly maxIdleWarmMs;
169
+ private readonly maxSubagentIdleMs;
170
+ private readonly tickIntervalMs;
171
+ private readonly maxTargets;
172
+ private readonly maxBytes;
173
+ private readonly warmTimeoutMs;
174
+ private readonly backoff;
175
+ private readonly getWindow;
176
+ private readonly getSustain;
177
+ private readonly setIntervalImpl;
178
+ private readonly clearIntervalImpl;
179
+ private timer;
180
+ private startedAt;
181
+ private totalBytes;
182
+ private tickPromise;
183
+ private disposed;
184
+ private abortController;
185
+ constructor(options: CacheKeepManagerOptions<M>);
186
+ /**
187
+ * Capture a session's latest request. The freshest body replaces the
188
+ * previous one and restarts its cache clock, idle clock and warm count.
189
+ * Arms the timer.
190
+ */
191
+ track(input: CacheKeepTrackInput<M>): CacheKeepTrackResult;
192
+ /** Forget a session, e.g. when the host deletes it. */
193
+ remove(sessionKey: string): void;
194
+ start(): void;
195
+ /** Disarm, abort any warm in flight and forget every target. */
196
+ stop(): void;
197
+ /** A snapshot without captured bodies, which can hold prompt content. */
198
+ status(): CacheKeepStatus;
199
+ /**
200
+ * Prune, then warm every target within `leadMs` of expiry that is not
201
+ * backing off. Overlapping calls share the tick already running, so a slow
202
+ * warm never stacks a second send for the same target.
203
+ */
204
+ tick(): Promise<void>;
205
+ private runTick;
206
+ private isCurrent;
207
+ private view;
208
+ private fail;
209
+ private warm;
210
+ private idleDefault;
211
+ private pruneStale;
212
+ private leastRecentlyTouched;
213
+ private drop;
214
+ }
@@ -0,0 +1,424 @@
1
+ import { isWithinCacheKeepWindow } from './window.js';
2
+ const DEFAULT_TTL_MS = 5 * 60_000;
3
+ const DEFAULT_MAX_IDLE_WARM_MS = 60 * 60_000;
4
+ const DEFAULT_MAX_SUBAGENT_IDLE_MS = 30 * 60_000;
5
+ const DEFAULT_TICK_INTERVAL_MS = 60_000;
6
+ const DEFAULT_MAX_TARGETS = 32;
7
+ const DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
8
+ const DEFAULT_WARM_TIMEOUT_MS = 30_000;
9
+ const DEFAULT_BACKOFF_MS = 10 * 60_000;
10
+ function positive(value, fallback) {
11
+ return typeof value === 'number' && Number.isFinite(value) && value > 0
12
+ ? value
13
+ : fallback;
14
+ }
15
+ function errorMessage(error) {
16
+ return error instanceof Error ? error.message : String(error);
17
+ }
18
+ /**
19
+ * Keeps idle prompt caches warm: one target per session holding the latest
20
+ * request body, replayed just before the provider's cache would expire.
21
+ *
22
+ * Bounds, in the order they act: the clock window gates capture and warming
23
+ * (targets captured earlier survive outside it); idle caps prune a target its
24
+ * session stopped using (`sustain` lifts only the main-session cap); the
25
+ * target-count and byte caps evict the least recently touched target; a
26
+ * per-target warm cap retires short-lived sessions; and a failed warm backs
27
+ * that target off without touching the others.
28
+ */
29
+ export class CacheKeepManager {
30
+ targets = new Map();
31
+ adapter;
32
+ now;
33
+ log;
34
+ ttlMs;
35
+ leadMs;
36
+ maxIdleWarmMs;
37
+ maxSubagentIdleMs;
38
+ tickIntervalMs;
39
+ maxTargets;
40
+ maxBytes;
41
+ warmTimeoutMs;
42
+ backoff;
43
+ getWindow;
44
+ getSustain;
45
+ setIntervalImpl;
46
+ clearIntervalImpl;
47
+ timer = null;
48
+ startedAt = null;
49
+ totalBytes = 0;
50
+ tickPromise = null;
51
+ disposed = false;
52
+ abortController = new AbortController();
53
+ constructor(options) {
54
+ this.adapter = options.adapter;
55
+ this.now = options.now;
56
+ this.log = options.logger;
57
+ this.ttlMs = positive(options.ttlMs, DEFAULT_TTL_MS);
58
+ this.tickIntervalMs = positive(options.tickIntervalMs, DEFAULT_TICK_INTERVAL_MS);
59
+ // One tick of slack so a target that becomes due between ticks is still
60
+ // warmed before its cache expires.
61
+ this.leadMs = positive(options.leadMs, this.tickIntervalMs + 15_000);
62
+ this.maxIdleWarmMs = positive(options.maxIdleWarmMs, DEFAULT_MAX_IDLE_WARM_MS);
63
+ this.maxSubagentIdleMs = positive(options.maxSubagentIdleMs, DEFAULT_MAX_SUBAGENT_IDLE_MS);
64
+ this.maxTargets = positive(options.maxTargets, DEFAULT_MAX_TARGETS);
65
+ this.maxBytes = positive(options.maxBytes, DEFAULT_MAX_BYTES);
66
+ this.warmTimeoutMs = positive(options.warmTimeoutMs, DEFAULT_WARM_TIMEOUT_MS);
67
+ this.backoff = options.backoffMs ?? DEFAULT_BACKOFF_MS;
68
+ this.getWindow = options.getWindow;
69
+ this.getSustain = options.getSustain;
70
+ this.setIntervalImpl = options.setIntervalImpl ?? globalThis.setInterval;
71
+ this.clearIntervalImpl =
72
+ options.clearIntervalImpl ?? globalThis.clearInterval;
73
+ }
74
+ /**
75
+ * Capture a session's latest request. The freshest body replaces the
76
+ * previous one and restarts its cache clock, idle clock and warm count.
77
+ * Arms the timer.
78
+ */
79
+ track(input) {
80
+ const { sessionKey, bodyText } = input;
81
+ this.pruneStale();
82
+ const bodyBytes = Buffer.byteLength(bodyText, 'utf8');
83
+ if (bodyBytes > this.maxBytes) {
84
+ this.log?.debug('cachekeep track skipped (body exceeds maxBytes)', {
85
+ sessionKey,
86
+ bodyBytes,
87
+ maxBytes: this.maxBytes,
88
+ });
89
+ return { tracked: false, reason: 'body-exceeds-max-bytes' };
90
+ }
91
+ const window = this.getWindow?.();
92
+ if (window && !isWithinCacheKeepWindow(window, new Date(this.now()))) {
93
+ this.log?.debug('cachekeep track skipped (outside window)', {
94
+ sessionKey,
95
+ startHour: window.startHour,
96
+ endHour: window.endHour,
97
+ });
98
+ return { tracked: false, reason: 'outside-window' };
99
+ }
100
+ // Delete before re-adding so a retracked session is also the most recent
101
+ // in insertion order.
102
+ this.drop(sessionKey);
103
+ while (this.targets.size >= this.maxTargets ||
104
+ (this.totalBytes + bodyBytes > this.maxBytes && this.targets.size > 0)) {
105
+ const evictKey = this.leastRecentlyTouched();
106
+ if (evictKey === undefined)
107
+ break;
108
+ this.log?.debug('cachekeep evicted least recently used target', {
109
+ sessionKey: evictKey,
110
+ });
111
+ this.drop(evictKey);
112
+ }
113
+ const isSubagent = input.isSubagent === true;
114
+ const accountId = input.accountId || undefined;
115
+ const profile = this.adapter.profile?.({
116
+ bodyText,
117
+ isSubagent,
118
+ accountId,
119
+ meta: input.meta,
120
+ });
121
+ const ttlMs = positive(profile?.ttlMs, this.ttlMs);
122
+ const now = this.now();
123
+ this.targets.set(sessionKey, {
124
+ bodyText,
125
+ bodyBytes,
126
+ accountId,
127
+ isSubagent,
128
+ meta: input.meta,
129
+ ttlMs,
130
+ maxWarms: profile?.maxWarms !== undefined && profile.maxWarms > 0
131
+ ? Math.floor(profile.maxWarms)
132
+ : undefined,
133
+ maxIdleMs: profile?.maxIdleMs !== undefined
134
+ ? positive(profile.maxIdleMs, this.idleDefault(isSubagent))
135
+ : undefined,
136
+ cacheExpiresAt: now + ttlMs,
137
+ lastRealRequestAt: now,
138
+ failures: 0,
139
+ warmCount: 0,
140
+ });
141
+ this.totalBytes += bodyBytes;
142
+ this.log?.debug('cachekeep captured target', { sessionKey, accountId });
143
+ this.start();
144
+ return { tracked: true };
145
+ }
146
+ /** Forget a session, e.g. when the host deletes it. */
147
+ remove(sessionKey) {
148
+ if (this.drop(sessionKey)) {
149
+ this.log?.debug('cachekeep removed target', { sessionKey });
150
+ }
151
+ }
152
+ start() {
153
+ this.disposed = false;
154
+ if (this.timer)
155
+ return;
156
+ this.startedAt = this.now();
157
+ this.timer = this.setIntervalImpl(() => {
158
+ void this.tick().catch((error) => {
159
+ this.log?.warn('cachekeep tick failed', { error: errorMessage(error) });
160
+ });
161
+ }, this.tickIntervalMs);
162
+ if (typeof this.timer === 'object' && 'unref' in this.timer) {
163
+ this.timer.unref();
164
+ }
165
+ this.log?.debug('cachekeep started', {
166
+ ttlMs: this.ttlMs,
167
+ leadMs: this.leadMs,
168
+ maxIdleWarmMs: this.maxIdleWarmMs,
169
+ });
170
+ }
171
+ /** Disarm, abort any warm in flight and forget every target. */
172
+ stop() {
173
+ this.disposed = true;
174
+ this.abortController.abort();
175
+ this.abortController = new AbortController();
176
+ if (this.timer) {
177
+ this.clearIntervalImpl(this.timer);
178
+ this.timer = null;
179
+ }
180
+ this.startedAt = null;
181
+ this.targets.clear();
182
+ this.totalBytes = 0;
183
+ this.log?.debug('cachekeep stopped');
184
+ }
185
+ /** A snapshot without captured bodies, which can hold prompt content. */
186
+ status() {
187
+ const window = this.getWindow?.();
188
+ return {
189
+ running: this.timer !== null,
190
+ tracked: this.targets.size,
191
+ totalBytes: this.totalBytes,
192
+ generatedAt: this.now(),
193
+ startedAt: this.startedAt,
194
+ ttlMs: this.ttlMs,
195
+ leadMs: this.leadMs,
196
+ maxIdleWarmMs: this.maxIdleWarmMs,
197
+ maxSubagentIdleMs: this.maxSubagentIdleMs,
198
+ sustain: this.getSustain?.() === true,
199
+ ...(window ? { window } : {}),
200
+ targets: [...this.targets].map(([sessionKey, target]) => ({
201
+ sessionKey,
202
+ accountId: target.accountId,
203
+ isSubagent: target.isSubagent,
204
+ cacheExpiresAt: target.cacheExpiresAt,
205
+ lastRealRequestAt: target.lastRealRequestAt,
206
+ ...(target.lastWarmedAt !== undefined
207
+ ? { lastWarmedAt: target.lastWarmedAt }
208
+ : {}),
209
+ ...(target.backoffUntil !== undefined
210
+ ? { backoffUntil: target.backoffUntil }
211
+ : {}),
212
+ failures: target.failures,
213
+ warmCount: target.warmCount,
214
+ ttlMs: target.ttlMs,
215
+ bodyBytes: target.bodyBytes,
216
+ })),
217
+ };
218
+ }
219
+ /**
220
+ * Prune, then warm every target within `leadMs` of expiry that is not
221
+ * backing off. Overlapping calls share the tick already running, so a slow
222
+ * warm never stacks a second send for the same target.
223
+ */
224
+ tick() {
225
+ if (this.tickPromise)
226
+ return this.tickPromise;
227
+ const run = this.runTick().finally(() => {
228
+ if (this.tickPromise === run)
229
+ this.tickPromise = null;
230
+ });
231
+ this.tickPromise = run;
232
+ return run;
233
+ }
234
+ async runTick() {
235
+ this.pruneStale();
236
+ // Outside the window nothing fires, but captured targets stay so they
237
+ // warm again when it reopens.
238
+ const window = this.getWindow?.();
239
+ if (window && !isWithinCacheKeepWindow(window, new Date(this.now()))) {
240
+ return;
241
+ }
242
+ const now = this.now();
243
+ const leadBound = now + this.leadMs;
244
+ for (const [sessionKey, target] of [...this.targets]) {
245
+ if (this.disposed)
246
+ return;
247
+ if (this.targets.get(sessionKey) !== target)
248
+ continue;
249
+ if (target.backoffUntil !== undefined && now < target.backoffUntil) {
250
+ continue;
251
+ }
252
+ if (target.cacheExpiresAt > leadBound)
253
+ continue;
254
+ await this.warm(sessionKey, target);
255
+ }
256
+ }
257
+ isCurrent(sessionKey, target) {
258
+ return !this.disposed && this.targets.get(sessionKey) === target;
259
+ }
260
+ view(sessionKey, target) {
261
+ return {
262
+ sessionKey,
263
+ accountId: target.accountId,
264
+ bodyText: target.bodyText,
265
+ isSubagent: target.isSubagent,
266
+ meta: target.meta,
267
+ warmCount: target.warmCount,
268
+ ttlMs: target.ttlMs,
269
+ };
270
+ }
271
+ fail(sessionKey, target, message, fields = {}) {
272
+ if (!this.isCurrent(sessionKey, target))
273
+ return;
274
+ target.failures += 1;
275
+ const delay = typeof this.backoff === 'function'
276
+ ? this.backoff({ sessionKey, failures: target.failures })
277
+ : this.backoff;
278
+ target.backoffUntil =
279
+ this.now() + (Number.isFinite(delay) && delay > 0 ? delay : 0);
280
+ this.log?.warn(message, {
281
+ sessionKey,
282
+ accountId: target.accountId,
283
+ failures: target.failures,
284
+ backoffUntil: target.backoffUntil,
285
+ ...fields,
286
+ });
287
+ }
288
+ async warm(sessionKey, target) {
289
+ const view = this.view(sessionKey, target);
290
+ if (this.adapter.activeAccount) {
291
+ let active;
292
+ try {
293
+ active = await this.adapter.activeAccount(sessionKey, view);
294
+ }
295
+ catch (error) {
296
+ this.fail(sessionKey, target, 'cachekeep skip (active account unknown)', {
297
+ error: errorMessage(error),
298
+ });
299
+ return;
300
+ }
301
+ if (!this.isCurrent(sessionKey, target))
302
+ return;
303
+ if (active !== undefined && active !== target.accountId) {
304
+ this.log?.debug('cachekeep dropped target (session changed account)', {
305
+ sessionKey,
306
+ accountId: target.accountId,
307
+ activeAccountId: active,
308
+ });
309
+ this.drop(sessionKey);
310
+ return;
311
+ }
312
+ }
313
+ let body;
314
+ try {
315
+ body = await this.adapter.buildBody(view);
316
+ }
317
+ catch (error) {
318
+ this.fail(sessionKey, target, 'cachekeep skip (replay body unbuildable)', {
319
+ error: errorMessage(error),
320
+ });
321
+ return;
322
+ }
323
+ if (!this.isCurrent(sessionKey, target))
324
+ return;
325
+ const signal = AbortSignal.any([
326
+ AbortSignal.timeout(this.warmTimeoutMs),
327
+ this.abortController.signal,
328
+ ]);
329
+ let response;
330
+ let text = '';
331
+ try {
332
+ response = await this.adapter.send({ target: view, body, signal });
333
+ // Drain the body so the connection is released; its text is only
334
+ // diagnostic.
335
+ text = await response.text().catch(() => '');
336
+ }
337
+ catch (error) {
338
+ this.fail(sessionKey, target, 'cachekeep failed', {
339
+ error: errorMessage(error),
340
+ });
341
+ return;
342
+ }
343
+ if (!this.isCurrent(sessionKey, target))
344
+ return;
345
+ if (!response.ok) {
346
+ this.fail(sessionKey, target, 'cachekeep failed', {
347
+ status: response.status,
348
+ responseBody: text.slice(0, 600),
349
+ });
350
+ return;
351
+ }
352
+ let usage;
353
+ try {
354
+ usage = this.adapter.readUsage?.({ target: view, response, text });
355
+ }
356
+ catch {
357
+ // Usage is for the log line only and must never fail a warm that worked.
358
+ }
359
+ const now = this.now();
360
+ target.cacheExpiresAt = now + target.ttlMs;
361
+ target.lastWarmedAt = now;
362
+ target.backoffUntil = undefined;
363
+ target.failures = 0;
364
+ target.warmCount += 1;
365
+ this.log?.debug('cachekeep fired', {
366
+ sessionKey,
367
+ accountId: target.accountId,
368
+ warmCount: target.warmCount,
369
+ ...usage,
370
+ });
371
+ if (target.maxWarms !== undefined && target.warmCount >= target.maxWarms) {
372
+ this.log?.debug('cachekeep retired target (warm cap reached)', {
373
+ sessionKey,
374
+ warmCount: target.warmCount,
375
+ });
376
+ this.drop(sessionKey);
377
+ }
378
+ }
379
+ idleDefault(isSubagent) {
380
+ return isSubagent ? this.maxSubagentIdleMs : this.maxIdleWarmMs;
381
+ }
382
+ pruneStale() {
383
+ const now = this.now();
384
+ const sustain = this.getSustain?.() === true;
385
+ for (const [sessionKey, target] of [...this.targets]) {
386
+ // Sustain exempts main sessions from the idle cap only; the window,
387
+ // count, byte and warm caps still apply to them.
388
+ if (sustain && !target.isSubagent)
389
+ continue;
390
+ const maxIdleMs = target.maxIdleMs ?? this.idleDefault(target.isSubagent);
391
+ if (target.lastRealRequestAt >= now - maxIdleMs)
392
+ continue;
393
+ this.log?.debug('cachekeep pruned idle target', {
394
+ sessionKey,
395
+ accountId: target.accountId,
396
+ lastRealRequestAt: target.lastRealRequestAt,
397
+ maxIdleMs,
398
+ });
399
+ this.drop(sessionKey);
400
+ }
401
+ }
402
+ leastRecentlyTouched() {
403
+ let evictKey;
404
+ let evictTouchedAt = Number.POSITIVE_INFINITY;
405
+ for (const [key, target] of this.targets) {
406
+ // A warm counts as a touch, so a session kept warm outlives one whose
407
+ // only activity is an older real request.
408
+ const touchedAt = Math.max(target.lastRealRequestAt, target.lastWarmedAt ?? 0);
409
+ if (touchedAt < evictTouchedAt) {
410
+ evictKey = key;
411
+ evictTouchedAt = touchedAt;
412
+ }
413
+ }
414
+ return evictKey;
415
+ }
416
+ drop(sessionKey) {
417
+ const target = this.targets.get(sessionKey);
418
+ if (!target)
419
+ return false;
420
+ this.totalBytes -= target.bodyBytes;
421
+ this.targets.delete(sessionKey);
422
+ return true;
423
+ }
424
+ }
@@ -0,0 +1,13 @@
1
+ /** A local clock-hour range in which idle caches are kept warm. */
2
+ export interface CacheKeepWindow {
3
+ startHour: number;
4
+ endHour: number;
5
+ }
6
+ /**
7
+ * Reads a persisted `{ startHour, endHour }` pair. Anything other than two
8
+ * distinct whole hours in 0-23 is no window at all, which callers treat as
9
+ * "always warm".
10
+ */
11
+ export declare function normalizeCacheKeepWindow(value: unknown): CacheKeepWindow | undefined;
12
+ /** Whether the local hour of `now` falls inside the window; no window is never inside. */
13
+ export declare function isWithinCacheKeepWindow(window: CacheKeepWindow | undefined, now?: Date): boolean;