@cortexkit/aft-opencode 0.18.3 → 0.19.0

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 (48) hide show
  1. package/dist/bg-notifications.d.ts.map +1 -1
  2. package/dist/configure-warnings.d.ts +31 -0
  3. package/dist/configure-warnings.d.ts.map +1 -0
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +17956 -17699
  6. package/dist/logger.d.ts +18 -0
  7. package/dist/logger.d.ts.map +1 -1
  8. package/dist/notifications.d.ts.map +1 -1
  9. package/dist/shared/last-assistant-model.d.ts +51 -0
  10. package/dist/shared/last-assistant-model.d.ts.map +1 -0
  11. package/dist/shared/rpc-client.d.ts +2 -0
  12. package/dist/shared/rpc-client.d.ts.map +1 -1
  13. package/dist/shared/session-directory.d.ts +32 -0
  14. package/dist/shared/session-directory.d.ts.map +1 -0
  15. package/dist/tools/_shared.d.ts +26 -4
  16. package/dist/tools/_shared.d.ts.map +1 -1
  17. package/dist/tools/bash.d.ts.map +1 -1
  18. package/dist/tools/hoisted.d.ts.map +1 -1
  19. package/dist/tools/reading.d.ts +14 -0
  20. package/dist/tools/reading.d.ts.map +1 -1
  21. package/dist/tui.js +36 -11
  22. package/dist/types.d.ts +1 -1
  23. package/dist/types.d.ts.map +1 -1
  24. package/dist/workflow-hints.d.ts +25 -0
  25. package/dist/workflow-hints.d.ts.map +1 -0
  26. package/package.json +8 -6
  27. package/src/logger.ts +19 -0
  28. package/src/shared/last-assistant-model.ts +179 -0
  29. package/src/shared/rpc-client.ts +38 -5
  30. package/src/shared/session-directory.ts +152 -0
  31. package/dist/bridge.d.ts +0 -122
  32. package/dist/bridge.d.ts.map +0 -1
  33. package/dist/downloader.d.ts +0 -35
  34. package/dist/downloader.d.ts.map +0 -1
  35. package/dist/onnx-runtime.d.ts +0 -53
  36. package/dist/onnx-runtime.d.ts.map +0 -1
  37. package/dist/platform.d.ts +0 -21
  38. package/dist/platform.d.ts.map +0 -1
  39. package/dist/pool.d.ts +0 -64
  40. package/dist/pool.d.ts.map +0 -1
  41. package/dist/resolver.d.ts +0 -36
  42. package/dist/resolver.d.ts.map +0 -1
  43. package/dist/shared/last-user-model.d.ts +0 -38
  44. package/dist/shared/last-user-model.d.ts.map +0 -1
  45. package/dist/shared/url-fetch.d.ts +0 -17
  46. package/dist/shared/url-fetch.d.ts.map +0 -1
  47. package/src/shared/last-user-model.ts +0 -133
  48. package/src/shared/url-fetch.ts +0 -420
@@ -1,133 +0,0 @@
1
- /**
2
- * Cache-busting fix: every PromptInput we send to OpenCode starts a new
3
- * assistant turn (via `noReply: false`) or stores a new user message
4
- * (via `noReply: true`). OpenCode's `createUserMessage` (in
5
- * `packages/opencode/src/session/prompt.ts`) only preserves the user's
6
- * model variant if we pass it explicitly — otherwise it falls back to
7
- * `agent.variant` or undefined, silently switching the next assistant
8
- * turn off the variant the human chose. That switch evicts the
9
- * provider's prefix cache on every notification.
10
- *
11
- * This module fetches the last real user message's `{ providerID,
12
- * modelID, variant }` and exposes it so prompt callers can include it
13
- * verbatim in `PromptInput.model` + `PromptInput.variant`. Results are
14
- * memoised per session for a short window so a batch of warnings or
15
- * bg-completions only hits the messages API once.
16
- */
17
-
18
- interface SessionMessageInfo {
19
- id?: string;
20
- role?: string;
21
- parts?: Array<{ ignored?: boolean }>;
22
- model?: {
23
- providerID?: string;
24
- modelID?: string;
25
- variant?: string;
26
- };
27
- }
28
-
29
- interface SessionMessage {
30
- info?: SessionMessageInfo;
31
- parts?: Array<{ ignored?: boolean }>;
32
- }
33
-
34
- interface OpenCodeClientShape {
35
- session?: {
36
- messages?: (input: { path: { id: string } }) => Promise<{ data?: SessionMessage[] }>;
37
- };
38
- }
39
-
40
- export interface LastUserModel {
41
- providerID: string;
42
- modelID: string;
43
- variant?: string;
44
- }
45
-
46
- interface CacheEntry {
47
- expiresAt: number;
48
- model: LastUserModel | null;
49
- }
50
-
51
- const CACHE_TTL_MS = 5_000;
52
- const CACHE_MAX_ENTRIES = 100;
53
- const cache = new Map<string, CacheEntry>();
54
-
55
- /**
56
- * Returns the most recent real user message's model + variant for the
57
- * session, or `null` when the session has no user messages or the
58
- * client doesn't expose `session.messages`. Result is cached for
59
- * {@link CACHE_TTL_MS} ms per session — long enough to coalesce a
60
- * batch of notifications, short enough that an actual variant change
61
- * inside the conversation is picked up promptly.
62
- *
63
- * NOTE: Skips synthetic user messages we just produced ourselves
64
- * (those whose only parts are `ignored: true`). Otherwise our own
65
- * announcement could pin the variant after a single round-trip and
66
- * defeat the fix.
67
- */
68
- export async function getLastUserModel(
69
- client: unknown,
70
- sessionId: string,
71
- ): Promise<LastUserModel | null> {
72
- const now = Date.now();
73
- const cached = cache.get(sessionId);
74
- if (cached && cached.expiresAt > now) {
75
- cache.delete(sessionId);
76
- cache.set(sessionId, cached);
77
- return cached.model;
78
- }
79
- if (cached) cache.delete(sessionId);
80
-
81
- const c = client as OpenCodeClientShape;
82
- const fetcher = c?.session?.messages;
83
- if (typeof fetcher !== "function") {
84
- return null;
85
- }
86
-
87
- let messages: SessionMessage[];
88
- try {
89
- const result = await fetcher({ path: { id: sessionId } });
90
- messages = result?.data ?? [];
91
- } catch {
92
- // Don't cache failures — a transient API error shouldn't pin the
93
- // session's variant resolution to "unknown" for 5 s.
94
- return null;
95
- }
96
-
97
- for (let i = messages.length - 1; i >= 0; i--) {
98
- const info = messages[i]?.info;
99
- if (info?.role !== "user") continue;
100
- if (isSyntheticIgnoredMessage(messages[i])) continue;
101
- const model = info.model;
102
- if (!model?.providerID || !model?.modelID) continue;
103
- const resolved: LastUserModel = {
104
- providerID: model.providerID,
105
- modelID: model.modelID,
106
- ...(model.variant ? { variant: model.variant } : {}),
107
- };
108
- setCache(sessionId, resolved);
109
- return resolved;
110
- }
111
-
112
- return null;
113
- }
114
-
115
- function setCache(sessionId: string, model: LastUserModel | null): void {
116
- if (cache.has(sessionId)) cache.delete(sessionId);
117
- cache.set(sessionId, { expiresAt: Date.now() + CACHE_TTL_MS, model });
118
- while (cache.size > CACHE_MAX_ENTRIES) {
119
- const oldest = cache.keys().next().value;
120
- if (typeof oldest !== "string") break;
121
- cache.delete(oldest);
122
- }
123
- }
124
-
125
- function isSyntheticIgnoredMessage(message: SessionMessage | undefined): boolean {
126
- const parts = message?.info?.parts ?? message?.parts;
127
- return Array.isArray(parts) && parts.length > 0 && parts.every((part) => part?.ignored === true);
128
- }
129
-
130
- /** Test-only: drop the cache so unit tests can simulate fresh sessions. */
131
- export function __resetLastUserModelCacheForTests(): void {
132
- cache.clear();
133
- }
@@ -1,420 +0,0 @@
1
- import { createHash } from "node:crypto";
2
- import { lookup } from "node:dns/promises";
3
- import {
4
- existsSync,
5
- mkdirSync,
6
- readdirSync,
7
- readFileSync,
8
- unlinkSync,
9
- writeFileSync,
10
- } from "node:fs";
11
- import { isIP } from "node:net";
12
- import { join } from "node:path";
13
- import { log, warn } from "../logger";
14
-
15
- /** Max response body size (10 MB) */
16
- const MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
17
- /** Cache TTL: 1 day */
18
- const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
19
- /** Fetch timeout: 30 seconds */
20
- const FETCH_TIMEOUT_MS = 30_000;
21
- const MAX_REDIRECTS = 5;
22
-
23
- interface FetchUrlOptions {
24
- allowPrivate?: boolean;
25
- }
26
-
27
- interface CacheMeta {
28
- url: string;
29
- contentType: string;
30
- extension: string;
31
- fetchedAt: number;
32
- }
33
-
34
- function cacheDir(storageDir: string): string {
35
- return join(storageDir, "url_cache");
36
- }
37
-
38
- function hashUrl(url: string): string {
39
- return createHash("sha256").update(url).digest("hex").slice(0, 16);
40
- }
41
-
42
- function metaPath(storageDir: string, hash: string): string {
43
- return join(cacheDir(storageDir), `${hash}.meta.json`);
44
- }
45
-
46
- function contentPath(storageDir: string, hash: string, extension: string): string {
47
- return join(cacheDir(storageDir), `${hash}${extension}`);
48
- }
49
-
50
- /** Exported for unit tests. */
51
- export function _isPrivateIpv4(address: string): boolean {
52
- const parts = address.split(".").map((part) => Number.parseInt(part, 10));
53
- if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part))) return true;
54
- const [a, b] = parts;
55
- return (
56
- a === 0 || // 0.0.0.0/8 — wildcard, blocked to prevent local-host bypass
57
- a === 10 ||
58
- a === 127 ||
59
- (a === 172 && b >= 16 && b <= 31) ||
60
- (a === 192 && b === 168) ||
61
- (a === 169 && b === 254) ||
62
- a >= 224 // multicast (224/4) and reserved (240/4)
63
- );
64
- }
65
-
66
- /**
67
- * Expand a possibly-compressed IPv6 address to 8 hextets (numbers).
68
- * Returns null on parse failure.
69
- */
70
- function expandIpv6(addr: string): number[] | null {
71
- if (addr === "::") return [0, 0, 0, 0, 0, 0, 0, 0];
72
- // Reject more than one "::"
73
- const dcMatches = addr.match(/::/g);
74
- if (dcMatches && dcMatches.length > 1) return null;
75
-
76
- // Handle dotted-quad tail by converting to two hextets.
77
- let normalized = addr;
78
- const lastColon = normalized.lastIndexOf(":");
79
- if (lastColon !== -1) {
80
- const tail = normalized.slice(lastColon + 1);
81
- if (tail.includes(".")) {
82
- const octets = tail.split(".").map((p) => Number.parseInt(p, 10));
83
- if (octets.length !== 4 || octets.some((o) => Number.isNaN(o) || o < 0 || o > 255)) {
84
- return null;
85
- }
86
- const h1 = ((octets[0] << 8) | octets[1]).toString(16);
87
- const h2 = ((octets[2] << 8) | octets[3]).toString(16);
88
- normalized = `${normalized.slice(0, lastColon)}:${h1}:${h2}`;
89
- }
90
- }
91
-
92
- let parts: string[];
93
- if (normalized.includes("::")) {
94
- const [left, right] = normalized.split("::");
95
- const leftParts = left ? left.split(":") : [];
96
- const rightParts = right ? right.split(":") : [];
97
- const fill = 8 - leftParts.length - rightParts.length;
98
- if (fill < 0) return null;
99
- parts = [...leftParts, ...Array(fill).fill("0"), ...rightParts];
100
- } else {
101
- parts = normalized.split(":");
102
- }
103
-
104
- if (parts.length !== 8) return null;
105
- const hextets = parts.map((p) => {
106
- const n = Number.parseInt(p, 16);
107
- return Number.isNaN(n) || n < 0 || n > 0xffff ? -1 : n;
108
- });
109
- if (hextets.some((h) => h === -1)) return null;
110
- return hextets;
111
- }
112
-
113
- function isPrivateIp(address: string): boolean {
114
- if (!address.includes(":")) {
115
- return _isPrivateIpv4(address);
116
- }
117
-
118
- const lower = address.toLowerCase();
119
- // Strip optional zone identifier (fe80::1%eth0 → fe80::1).
120
- const noZone = lower.split("%")[0];
121
-
122
- // Expand to 8 hextets so we can reliably detect IPv4-mapped/compatible
123
- // forms even after URL canonicalization (e.g. [::ffff:127.0.0.1] →
124
- // [::ffff:7f00:1]).
125
- const hextets = expandIpv6(noZone);
126
- if (hextets) {
127
- // IPv4-mapped (::ffff:X.X.X.X) and IPv4-compatible (::X.X.X.X) — extract
128
- // the embedded IPv4 from the last two hextets and check against IPv4 ranges.
129
- const top6Zero = hextets.slice(0, 6).every((h) => h === 0);
130
- const isMapped =
131
- hextets[0] === 0 &&
132
- hextets[1] === 0 &&
133
- hextets[2] === 0 &&
134
- hextets[3] === 0 &&
135
- hextets[4] === 0 &&
136
- hextets[5] === 0xffff;
137
- if (isMapped || top6Zero) {
138
- const a = (hextets[6] >> 8) & 0xff;
139
- const b = hextets[6] & 0xff;
140
- const c = (hextets[7] >> 8) & 0xff;
141
- const d = hextets[7] & 0xff;
142
- const ipv4 = `${a}.${b}.${c}.${d}`;
143
- // ::1 (loopback) and :: (unspecified) both fall into top6Zero and last
144
- // hextet 0 or 1 — _isPrivateIpv4 already blocks 0.0.0.0 and 127/8.
145
- if (top6Zero && hextets[6] === 0 && hextets[7] <= 1) {
146
- return true;
147
- }
148
- return _isPrivateIpv4(ipv4);
149
- }
150
-
151
- const firstHextet = hextets[0];
152
- return (
153
- // fe80::/10 link-local
154
- (firstHextet >= 0xfe80 && firstHextet <= 0xfebf) ||
155
- // fc00::/7 unique local
156
- (firstHextet >= 0xfc00 && firstHextet <= 0xfdff) ||
157
- // ff00::/8 multicast
158
- firstHextet >= 0xff00
159
- );
160
- }
161
-
162
- // Could not expand — be conservative and block.
163
- return true;
164
- }
165
-
166
- async function assertPublicUrl(url: URL, allowPrivate: boolean): Promise<void> {
167
- if (url.protocol !== "http:" && url.protocol !== "https:") {
168
- throw new Error(`Only http:// and https:// URLs are supported, got: ${url.protocol}`);
169
- }
170
- if (allowPrivate) return;
171
-
172
- // URL.hostname returns IPv6 literals wrapped in brackets ("[::1]") per WHATWG.
173
- // Strip them before checking the address.
174
- const hostname = url.hostname.replace(/^\[|\]$/g, "");
175
-
176
- // If the hostname is already a literal IP, check it directly — skip DNS so we
177
- // also catch malformed IPv6 forms (::127.0.0.1, ::ffff:127.0.0.1) that some
178
- // resolvers reject as "not found" instead of returning the embedded IPv4.
179
- if (isIP(hostname) || hostname.includes(":")) {
180
- if (isPrivateIp(hostname)) {
181
- throw new Error(`Blocked private URL host ${url.hostname} (${hostname})`);
182
- }
183
- return;
184
- }
185
-
186
- const addresses = await lookup(hostname, { all: true, verbatim: true });
187
- for (const { address } of addresses) {
188
- if (isPrivateIp(address)) {
189
- throw new Error(`Blocked private URL host ${url.hostname} (${address})`);
190
- }
191
- }
192
- }
193
-
194
- function resolveRedirectUrl(currentUrl: URL, location: string | null): URL {
195
- if (!location) {
196
- throw new Error(`Redirect from ${currentUrl.href} missing Location header`);
197
- }
198
- return new URL(location, currentUrl);
199
- }
200
-
201
- async function fetchWithRedirects(startUrl: URL, allowPrivate: boolean): Promise<Response> {
202
- let currentUrl = startUrl;
203
-
204
- for (let redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount++) {
205
- await assertPublicUrl(currentUrl, allowPrivate);
206
-
207
- const controller = new AbortController();
208
- const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
209
- let response: Response;
210
- try {
211
- response = await fetch(currentUrl.href, {
212
- signal: controller.signal,
213
- redirect: "manual",
214
- headers: {
215
- "user-agent": "aft-opencode-plugin",
216
- // Prioritize markdown for content-negotiating servers (GitHub API, many docs sites).
217
- // `application/vnd.github.raw` is GitHub's custom type — returns raw markdown from
218
- // repo file and readme endpoints. Falls back to HTML if markdown is not available.
219
- accept:
220
- "application/vnd.github.raw, text/markdown, text/x-markdown, text/html;q=0.9, text/plain;q=0.5",
221
- },
222
- });
223
- } catch (err) {
224
- throw new Error(`Failed to fetch ${currentUrl.href}: ${(err as Error).message}`);
225
- } finally {
226
- clearTimeout(timer);
227
- }
228
-
229
- if (response.status < 300 || response.status >= 400) {
230
- return response;
231
- }
232
-
233
- if (redirectCount === MAX_REDIRECTS) {
234
- throw new Error(`Too many redirects fetching ${startUrl.href}`);
235
- }
236
- currentUrl = resolveRedirectUrl(currentUrl, response.headers.get("location"));
237
- }
238
-
239
- throw new Error(`Too many redirects fetching ${startUrl.href}`);
240
- }
241
-
242
- /**
243
- * Map a Content-Type header to a file extension AFT can parse.
244
- * Returns null for unsupported types.
245
- */
246
- function resolveExtension(contentType: string): string | null {
247
- // Normalize: strip parameters (after `;`) AND pick the first value if the server
248
- // echoed back a comma-separated list (GitHub API does this for /readme endpoints).
249
- const lower = contentType.toLowerCase().split(";")[0].split(",")[0].trim();
250
- if (
251
- lower === "text/html" ||
252
- lower === "application/xhtml+xml" ||
253
- lower === "application/vnd.github.html" ||
254
- lower === "application/vnd.github+html"
255
- ) {
256
- return ".html";
257
- }
258
- if (
259
- lower === "text/markdown" ||
260
- lower === "text/x-markdown" ||
261
- lower === "application/markdown" ||
262
- // GitHub API raw content type — returns raw markdown for README endpoints
263
- lower === "application/vnd.github.raw" ||
264
- lower === "application/vnd.github+raw" ||
265
- lower === "application/vnd.github.v3.raw"
266
- ) {
267
- return ".md";
268
- }
269
- if (lower === "text/plain") {
270
- // treat plain text as markdown so aft_outline can show headings if present
271
- return ".md";
272
- }
273
- return null;
274
- }
275
-
276
- /**
277
- * Fetch a URL to a cached temp file. Uses disk cache with 1-day TTL.
278
- * Returns the cached file path the Rust outline/zoom command can read.
279
- * Throws on errors (invalid URL, network failure, unsupported content type, oversized body).
280
- */
281
- export async function fetchUrlToTempFile(
282
- url: string,
283
- storageDir: string,
284
- options: FetchUrlOptions = {},
285
- ): Promise<string> {
286
- let parsed: URL;
287
- try {
288
- parsed = new URL(url);
289
- } catch {
290
- throw new Error(`Invalid URL: ${url}`);
291
- }
292
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
293
- throw new Error(`Only http:// and https:// URLs are supported, got: ${parsed.protocol}`);
294
- }
295
- const allowPrivate = options.allowPrivate === true;
296
-
297
- const dir = cacheDir(storageDir);
298
- mkdirSync(dir, { recursive: true });
299
-
300
- const hash = hashUrl(url);
301
- const metaFile = metaPath(storageDir, hash);
302
-
303
- // Check cache
304
- if (existsSync(metaFile)) {
305
- try {
306
- const meta = JSON.parse(readFileSync(metaFile, "utf8")) as CacheMeta;
307
- const age = Date.now() - meta.fetchedAt;
308
- const cached = contentPath(storageDir, hash, meta.extension);
309
- if (age < CACHE_TTL_MS && existsSync(cached)) {
310
- log(`URL cache hit: ${url} (${Math.round(age / 1000)}s old)`);
311
- return cached;
312
- }
313
- } catch {
314
- // corrupted meta, re-fetch
315
- }
316
- }
317
-
318
- log(`Fetching URL: ${url}`);
319
- const response = await fetchWithRedirects(parsed, allowPrivate);
320
-
321
- if (!response.ok) {
322
- throw new Error(`HTTP ${response.status} ${response.statusText} fetching ${url}`);
323
- }
324
-
325
- const contentType = response.headers.get("content-type") || "text/plain";
326
- const extension = resolveExtension(contentType);
327
- if (!extension) {
328
- throw new Error(
329
- `Unsupported content type '${contentType}' for ${url}. Supported: text/html, text/markdown, text/plain`,
330
- );
331
- }
332
-
333
- const lengthHeader = response.headers.get("content-length");
334
- if (lengthHeader) {
335
- const length = Number.parseInt(lengthHeader, 10);
336
- if (Number.isFinite(length) && length > MAX_RESPONSE_BYTES) {
337
- throw new Error(`Response too large: ${length} bytes (max ${MAX_RESPONSE_BYTES})`);
338
- }
339
- }
340
-
341
- // Stream with size cap
342
- const reader = response.body?.getReader();
343
- if (!reader) {
344
- throw new Error(`Failed to read response body for ${url}`);
345
- }
346
- const chunks: Uint8Array[] = [];
347
- let total = 0;
348
- while (true) {
349
- const { done, value } = await reader.read();
350
- if (done) break;
351
- if (value) {
352
- total += value.length;
353
- if (total > MAX_RESPONSE_BYTES) {
354
- reader.cancel().catch(() => {});
355
- throw new Error(`Response exceeded ${MAX_RESPONSE_BYTES} bytes, aborted`);
356
- }
357
- chunks.push(value);
358
- }
359
- }
360
-
361
- // Write content and meta atomically
362
- const body = Buffer.concat(chunks);
363
- const contentFile = contentPath(storageDir, hash, extension);
364
- const tmpContent = `${contentFile}.tmp-${process.pid}`;
365
- writeFileSync(tmpContent, body);
366
- const { renameSync } = await import("node:fs");
367
- renameSync(tmpContent, contentFile);
368
-
369
- const meta: CacheMeta = {
370
- url,
371
- contentType,
372
- extension,
373
- fetchedAt: Date.now(),
374
- };
375
- const tmpMeta = `${metaFile}.tmp-${process.pid}`;
376
- writeFileSync(tmpMeta, JSON.stringify(meta));
377
- renameSync(tmpMeta, metaFile);
378
-
379
- log(`URL cached (${total} bytes): ${url}`);
380
- return contentFile;
381
- }
382
-
383
- /**
384
- * Remove cache entries older than TTL. Called periodically at plugin startup.
385
- */
386
- export function cleanupUrlCache(storageDir: string): void {
387
- const dir = cacheDir(storageDir);
388
- if (!existsSync(dir)) return;
389
-
390
- let removed = 0;
391
- try {
392
- for (const entry of readdirSync(dir)) {
393
- if (!entry.endsWith(".meta.json")) continue;
394
- const metaFile = join(dir, entry);
395
- try {
396
- const meta = JSON.parse(readFileSync(metaFile, "utf8")) as CacheMeta;
397
- const age = Date.now() - meta.fetchedAt;
398
- if (age > CACHE_TTL_MS) {
399
- const hash = entry.slice(0, -".meta.json".length);
400
- const content = contentPath(storageDir, hash, meta.extension);
401
- if (existsSync(content)) unlinkSync(content);
402
- unlinkSync(metaFile);
403
- removed++;
404
- }
405
- } catch {
406
- // corrupted meta, remove it too
407
- try {
408
- unlinkSync(metaFile);
409
- removed++;
410
- } catch {}
411
- }
412
- }
413
- } catch (err) {
414
- warn(`URL cache cleanup failed: ${(err as Error).message}`);
415
- return;
416
- }
417
- if (removed > 0) {
418
- log(`URL cache cleanup: removed ${removed} stale entries`);
419
- }
420
- }