@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.
- package/dist/auth-menu/accounts.d.ts +88 -0
- package/dist/auth-menu/accounts.js +401 -0
- package/dist/auth-menu/ansi.d.ts +24 -0
- package/dist/auth-menu/ansi.js +67 -0
- package/dist/auth-menu/confirm.d.ts +7 -0
- package/dist/auth-menu/confirm.js +21 -0
- package/dist/auth-menu/doctor.d.ts +52 -0
- package/dist/auth-menu/doctor.js +81 -0
- package/dist/auth-menu/index.d.ts +17 -0
- package/dist/auth-menu/index.js +12 -0
- package/dist/auth-menu/login.d.ts +47 -0
- package/dist/auth-menu/login.js +63 -0
- package/dist/auth-menu/menu.d.ts +59 -0
- package/dist/auth-menu/menu.js +73 -0
- package/dist/auth-menu/opencode-v1.d.ts +49 -0
- package/dist/auth-menu/opencode-v1.js +47 -0
- package/dist/auth-menu/select.d.ts +21 -0
- package/dist/auth-menu/select.js +153 -0
- package/dist/auth-menu/terminal.d.ts +43 -0
- package/dist/auth-menu/terminal.js +19 -0
- package/dist/cachekeep/index.d.ts +4 -0
- package/dist/cachekeep/index.js +2 -0
- package/dist/cachekeep/manager.d.ts +214 -0
- package/dist/cachekeep/manager.js +424 -0
- package/dist/cachekeep/window.d.ts +13 -0
- package/dist/cachekeep/window.js +33 -0
- package/dist/claustrum/index.d.ts +1 -0
- package/dist/claustrum/index.js +2 -0
- package/dist/commands/index.d.ts +1 -0
- package/dist/commands/index.js +2 -0
- package/dist/dump/index.d.ts +90 -0
- package/dist/dump/index.js +292 -0
- package/dist/opencode2/index.d.ts +1 -0
- package/dist/opencode2/index.js +2 -0
- package/dist/store/errors.d.ts +2 -2
- package/dist/store/hooks.d.ts +1 -1
- package/dist/store/index.d.ts +1 -1
- package/dist/store/mutate.d.ts +3 -2
- package/dist/store/mutate.js +2 -1
- package/dist/store/pool.d.ts +8 -1
- package/dist/store/pool.js +2 -1
- package/dist/store/rows.d.ts +28 -1
- package/dist/store/rows.js +93 -0
- 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,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;
|