aether-mcp-server 2.0.2 → 2.1.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.
package/dist/index.js CHANGED
@@ -3,17 +3,16 @@
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  const index_js_1 = require("@modelcontextprotocol/sdk/server/index.js");
5
5
  const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
6
- const ws_server_1 = require("./ws-server");
7
6
  const mcp_server_1 = require("./mcp-server");
8
7
  const cdp_client_1 = require("./cdp-client");
9
- const WS_PORT = 3009;
10
- const MODE = process.env.AETHER_MODE || "cdp"; // "cdp" or "extension"
8
+ const logger_1 = require("./logger");
9
+ const log = (0, logger_1.createLogger)("index");
11
10
  // Graceful shutdown handler
12
11
  async function shutdown() {
13
- console.error("\n[Server] Shutting down...");
12
+ log.info("Shutting down...");
14
13
  const client = (0, cdp_client_1.getCdpClient)();
15
14
  if (client.isConnected()) {
16
- console.error("[Server] Killing browser...");
15
+ log.info("Killing browser...");
17
16
  await client.killBrowser();
18
17
  }
19
18
  process.exit(0);
@@ -22,11 +21,11 @@ async function shutdown() {
22
21
  process.on("SIGINT", shutdown);
23
22
  process.on("SIGTERM", shutdown);
24
23
  process.on("uncaughtException", (err) => {
25
- console.error("[Server] Uncaught Exception:", err);
24
+ log.error("Uncaught Exception", { error: String(err) });
26
25
  shutdown();
27
26
  });
28
27
  process.on("unhandledRejection", (reason, promise) => {
29
- console.error("[Server] Unhandled Rejection at:", promise, "reason:", reason);
28
+ log.error("Unhandled Rejection", { reason: String(reason) });
30
29
  shutdown();
31
30
  });
32
31
  process.on("exit", () => {
@@ -36,36 +35,25 @@ process.on("exit", () => {
36
35
  }
37
36
  });
38
37
  async function main() {
39
- console.error(`Starting MCP Browser Server (mode: ${MODE})...`);
40
- let wsServer = null;
41
- if (MODE === "extension") {
42
- // 1. Kill any stale server on the port
43
- await (0, ws_server_1.ensurePortAvailable)(WS_PORT);
44
- // 2. Start WebSocket Server for Extension
45
- wsServer = (0, ws_server_1.StartWebSocketServer)(WS_PORT);
46
- console.error("WebSocket server started for extension mode");
47
- }
48
- else {
49
- console.error("CDP mode enabled - no extension needed");
50
- console.error("To connect to a browser, use the 'launch_browser' or 'connect_browser' tool");
51
- }
52
- // 3. Initialize MCP Server
38
+ log.info("Starting Aether MCP Browser Server (CDP mode — no extension needed)...");
39
+ log.info("Use 'launch_browser' or 'connect_browser' tool to connect to a browser.");
40
+ // Initialize MCP Server
53
41
  const server = new index_js_1.Server({
54
- name: "mcp-browser-control",
55
- version: "2.0.0",
42
+ name: "aether-mcp-server",
43
+ version: "2.1.0",
56
44
  }, {
57
45
  capabilities: {
58
46
  tools: {},
59
47
  },
60
48
  });
61
- // 4. Register Tools
62
- (0, mcp_server_1.RegisterMcpTools)(server, wsServer);
63
- // 5. Connect Transport
49
+ // Register Tools
50
+ (0, mcp_server_1.RegisterMcpTools)(server);
51
+ // Connect Transport
64
52
  const transport = new stdio_js_1.StdioServerTransport();
65
53
  await server.connect(transport);
66
- console.error("MCP Server connected via Stdio");
54
+ log.info("MCP Server connected via Stdio. Ready.");
67
55
  }
68
56
  main().catch((err) => {
69
- console.error("Fatal Error:", err);
57
+ log.error("Fatal Error", { error: String(err) });
70
58
  process.exit(1);
71
59
  });
@@ -9,15 +9,12 @@ class LocatorEngine {
9
9
  }
10
10
  async list(maxElements = 50, includeText = true, withOverlay = false) {
11
11
  const capped = Math.max(0, Math.min(Number(maxElements || 50), 200));
12
- const result = await this.client.evaluate(locatorScript({
13
- target: "",
14
- role: "",
15
- selector: "",
16
- maxCandidates: capped,
17
- includeText,
18
- mode: "list",
19
- }));
20
- const candidates = normalizeCandidates(result?.candidates).slice(0, capped);
12
+ const result = await this.client.evaluate(`window.__aetherLocate(${JSON.stringify(JSON.stringify({
13
+ target: "", role: "", selector: "",
14
+ maxCandidates: capped, includeText, mode: "list",
15
+ }))})`);
16
+ const parsed = safeJsonParse(result);
17
+ const candidates = normalizeCandidates(parsed?.candidates).slice(0, capped);
21
18
  if (withOverlay && candidates.length > 0) {
22
19
  await this.client.getInteractiveElements(true).catch(() => ({ elements: [], somInjected: false }));
23
20
  }
@@ -34,14 +31,11 @@ class LocatorEngine {
34
31
  return { success: false, message: "target, role, or selector required" };
35
32
  }
36
33
  while (Date.now() - started < timeout) {
37
- const result = await this.client.evaluate(locatorScript({
38
- target,
39
- role,
40
- selector,
41
- maxCandidates,
42
- includeText: true,
43
- mode: "resolve",
44
- })).catch((error) => ({ error: error.message }));
34
+ const resultJson = await this.client.evaluate(`window.__aetherLocate(${JSON.stringify(JSON.stringify({
35
+ target, role, selector: selector,
36
+ maxCandidates, includeText: true, mode: "resolve",
37
+ }))})`).catch((error) => ({ error: error.message }));
38
+ const result = safeJsonParse(resultJson);
45
39
  const candidates = normalizeCandidates(result?.candidates);
46
40
  const best = candidates[0];
47
41
  if (best && (selector || best.score > 0 || role)) {
@@ -81,246 +75,14 @@ function normalizeCandidates(value) {
81
75
  function sleep(ms) {
82
76
  return new Promise((resolve) => setTimeout(resolve, ms));
83
77
  }
84
- function locatorScript(input) {
85
- return `
86
- (function() {
87
- const target = ${JSON.stringify(input.target)};
88
- const targetLower = target.toLowerCase();
89
- const roleHint = ${JSON.stringify(input.role)};
90
- const selectorHint = ${JSON.stringify(input.selector)};
91
- const maxCandidates = ${JSON.stringify(input.maxCandidates)};
92
- const includeText = ${JSON.stringify(input.includeText)};
93
- const mode = ${JSON.stringify(input.mode)};
94
- const interactiveSelector = [
95
- 'a[href]', 'button', 'input:not([type="hidden"])', 'select', 'textarea',
96
- '[onclick]', '[role]', '[tabindex]:not([tabindex="-1"])', 'label', 'summary',
97
- '[contenteditable="true"]', '[aria-label]', '[placeholder]'
98
- ].join(', ');
99
-
100
- function norm(value) {
101
- return String(value || '').trim().replace(/\\s+/g, ' ');
102
- }
103
-
104
- function visible(el) {
105
- const rect = el.getBoundingClientRect();
106
- const style = window.getComputedStyle(el);
107
- return style.display !== 'none' &&
108
- style.visibility !== 'hidden' &&
109
- style.opacity !== '0' &&
110
- rect.width > 0 &&
111
- rect.height > 0;
112
- }
113
-
114
- function cssPath(el) {
115
- if (!el || el.nodeType !== Node.ELEMENT_NODE) return '';
116
- if (el.id) return '#' + CSS.escape(el.id);
117
- const path = [];
118
- let node = el;
119
- while (node && node.nodeType === Node.ELEMENT_NODE && node !== node.ownerDocument.body) {
120
- let part = node.nodeName.toLowerCase();
121
- if (node.classList && node.classList.length) {
122
- part += '.' + Array.from(node.classList).slice(0, 2).map((c) => CSS.escape(c)).join('.');
123
- }
124
- const parent = node.parentElement;
125
- if (parent) {
126
- const same = Array.from(parent.children).filter((child) => child.nodeName === node.nodeName);
127
- if (same.length > 1) part += ':nth-of-type(' + (same.indexOf(node) + 1) + ')';
128
- }
129
- path.unshift(part);
130
- node = parent;
131
- }
132
- return path.join(' > ');
133
- }
134
-
135
- function xpath(el) {
136
- const parts = [];
137
- let node = el;
138
- while (node && node.nodeType === Node.ELEMENT_NODE) {
139
- let index = 1;
140
- let sibling = node.previousElementSibling;
141
- while (sibling) {
142
- if (sibling.nodeName === node.nodeName) index++;
143
- sibling = sibling.previousElementSibling;
144
- }
145
- parts.unshift(node.nodeName.toLowerCase() + '[' + index + ']');
146
- node = node.parentElement;
147
- }
148
- return '/' + parts.join('/');
149
- }
150
-
151
- function inferRole(el) {
152
- const explicit = (el.getAttribute('role') || '').toLowerCase();
153
- if (explicit) return explicit;
154
- const tag = el.tagName.toLowerCase();
155
- const type = (el.getAttribute('type') || '').toLowerCase();
156
- if (tag === 'button' || type === 'button' || type === 'submit' || type === 'reset') return 'button';
157
- if (tag === 'a') return 'link';
158
- if (tag === 'textarea') return 'textbox';
159
- if (tag === 'select') return 'combobox';
160
- if (tag === 'input' && ['checkbox', 'radio'].includes(type)) return type;
161
- if (tag === 'input') return 'textbox';
162
- if (el.isContentEditable) return 'textbox';
163
- if (tag === 'summary') return 'button';
164
- return tag;
165
- }
166
-
167
- function byId(doc, id) {
168
- if (!id) return '';
169
- const el = doc.getElementById(id);
170
- return el ? norm(el.innerText || el.textContent) : '';
171
- }
172
-
173
- function labelFor(el) {
174
- const doc = el.ownerDocument;
175
- const labelledBy = norm((el.getAttribute('aria-labelledby') || '').split(/\\s+/).map((id) => byId(doc, id)).join(' '));
176
- if (labelledBy) return labelledBy;
177
- if (el.id) {
178
- const direct = doc.querySelector('label[for="' + CSS.escape(el.id) + '"]');
179
- if (direct) return norm(direct.innerText || direct.textContent);
180
- }
181
- const wrapping = el.closest && el.closest('label');
182
- return wrapping ? norm(wrapping.innerText || wrapping.textContent) : '';
183
- }
184
-
185
- function textFor(el) {
186
- return norm(
187
- el.getAttribute('aria-label') ||
188
- labelFor(el) ||
189
- el.getAttribute('placeholder') ||
190
- el.getAttribute('alt') ||
191
- el.getAttribute('title') ||
192
- el.innerText ||
193
- el.textContent ||
194
- el.getAttribute('value') ||
195
- el.getAttribute('name') ||
196
- ''
197
- );
198
- }
199
-
200
- function scoreField(field, value, exact, includes) {
201
- const text = norm(value);
202
- const lower = text.toLowerCase();
203
- if (!targetLower) return { score: 0, by: '' };
204
- if (lower === targetLower) return { score: exact, by: field + ':exact' };
205
- if (lower.includes(targetLower)) return { score: includes, by: field + ':contains' };
206
- if (targetLower.includes(lower) && lower.length >= 3) return { score: Math.max(1, includes - 1), by: field + ':contained_by_target' };
207
- return { score: 0, by: '' };
208
- }
209
-
210
- function scoreCandidate(item) {
211
- let score = 0;
212
- let matchedBy = '';
213
- const fields = [
214
- ['selector', item.selector, 13, 10],
215
- ['xpath', item.xpath, 11, 8],
216
- ['name', item.name, 12, 10],
217
- ['label', item.label, 12, 10],
218
- ['placeholder', item.placeholder, 11, 9],
219
- ['text', item.text, 10, 8],
220
- ['title', item.title, 8, 6],
221
- ['value', item.value, 7, 5]
222
- ];
223
- for (const field of fields) {
224
- const match = scoreField(field[0], field[1], field[2], field[3]);
225
- if (match.score > score) {
226
- score = match.score;
227
- matchedBy = match.by;
228
- }
229
- }
230
- if (roleHint) {
231
- if (item.role === roleHint) score += 5;
232
- else if (roleHint === 'textbox' && ['input', 'textarea'].includes(item.tag)) score += 3;
233
- else score -= 3;
234
- }
235
- if (!targetLower && roleHint && item.role === roleHint) {
236
- matchedBy = 'role';
237
- score = Math.max(score, 5);
238
- }
239
- if (item.scope !== 'document') score += 1;
240
- item.score = score;
241
- item.matchedBy = matchedBy || (selectorHint ? 'selector' : '');
242
- return item;
243
- }
244
-
245
- function collectFromRoot(root, framePath, frameOffset, shadowDepth, scope, out) {
246
- let nodes = [];
247
- try {
248
- if (selectorHint) {
249
- nodes = Array.from(root.querySelectorAll(selectorHint));
250
- } else {
251
- nodes = Array.from(root.querySelectorAll(interactiveSelector));
252
- }
253
- } catch {
254
- nodes = [];
255
- }
256
-
257
- for (const el of nodes) {
258
- if (!visible(el)) continue;
259
- const rect = el.getBoundingClientRect();
260
- const selector = cssPath(el);
261
- const role = inferRole(el);
262
- const label = labelFor(el);
263
- const item = {
264
- ref: '',
265
- selector,
266
- xpath: xpath(el),
267
- scope,
268
- framePath: framePath.slice(),
269
- shadowDepth,
270
- tag: el.tagName.toLowerCase(),
271
- role,
272
- type: el.getAttribute('type') || '',
273
- name: norm(el.getAttribute('aria-label') || label || textFor(el) || el.getAttribute('name')),
274
- text: includeText ? norm(el.innerText || el.textContent).substring(0, 180) : '',
275
- label,
276
- placeholder: norm(el.getAttribute('placeholder')),
277
- title: norm(el.getAttribute('title')),
278
- value: norm(el.getAttribute('value')),
279
- score: 0,
280
- matchedBy: '',
281
- bounds: {
282
- x: Math.round(frameOffset.x + rect.left),
283
- y: Math.round(frameOffset.y + rect.top),
284
- width: Math.round(rect.width),
285
- height: Math.round(rect.height)
286
- }
287
- };
288
- item.ref = item.scope === 'document' && item.framePath.length === 0 && item.shadowDepth === 0
289
- ? 'css:' + item.selector
290
- : 'point:' + Math.round(item.bounds.x + item.bounds.width / 2) + ',' + Math.round(item.bounds.y + item.bounds.height / 2);
291
- out.push(scoreCandidate(item));
292
- }
293
-
294
- const all = [];
295
- try {
296
- all.push(...Array.from(root.querySelectorAll('*')));
297
- } catch {}
298
-
299
- for (const el of all) {
300
- if (el.shadowRoot) {
301
- collectFromRoot(el.shadowRoot, framePath, frameOffset, shadowDepth + 1, 'shadow', out);
302
- }
303
- if (el.tagName && el.tagName.toLowerCase() === 'iframe') {
304
- try {
305
- const doc = el.contentDocument;
306
- if (!doc) continue;
307
- const rect = el.getBoundingClientRect();
308
- collectFromRoot(doc, framePath.concat([Array.from(el.ownerDocument.querySelectorAll('iframe')).indexOf(el)]), {
309
- x: frameOffset.x + rect.left,
310
- y: frameOffset.y + rect.top
311
- }, shadowDepth, 'frame', out);
312
- } catch {}
313
- }
314
- }
315
- }
316
-
317
- const candidates = [];
318
- collectFromRoot(document, [], { x: 0, y: 0 }, 0, 'document', candidates);
319
- candidates.sort((a, b) => {
320
- if (mode === 'list') return (a.bounds.y - b.bounds.y) || (a.bounds.x - b.bounds.x);
321
- return b.score - a.score || a.bounds.y - b.bounds.y || a.bounds.x - b.bounds.x;
322
- });
323
- return { candidates: candidates.slice(0, maxCandidates) };
324
- })()
325
- `;
78
+ function safeJsonParse(value) {
79
+ if (typeof value === "string") {
80
+ try {
81
+ return JSON.parse(value);
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ }
87
+ return value;
326
88
  }
package/dist/logger.js ADDED
@@ -0,0 +1,105 @@
1
+ "use strict";
2
+ /**
3
+ * Aether structured logger with correlation IDs for agent action tracing.
4
+ * Replaces ad-hoc console.error calls with leveled, structured output.
5
+ */
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.rootLogger = void 0;
8
+ exports.setLogLevel = setLogLevel;
9
+ exports.onLog = onLog;
10
+ exports.createLogger = createLogger;
11
+ let globalLogLevel = "info";
12
+ const listeners = [];
13
+ function setLogLevel(level) {
14
+ globalLogLevel = level;
15
+ }
16
+ function onLog(fn) {
17
+ listeners.push(fn);
18
+ return () => {
19
+ const idx = listeners.indexOf(fn);
20
+ if (idx >= 0)
21
+ listeners.splice(idx, 1);
22
+ };
23
+ }
24
+ const LEVEL_PRIO = {
25
+ debug: 0,
26
+ info: 1,
27
+ warn: 2,
28
+ error: 3,
29
+ };
30
+ function shouldLog(level) {
31
+ return LEVEL_PRIO[level] >= LEVEL_PRIO[globalLogLevel];
32
+ }
33
+ function emit(entry) {
34
+ if (!shouldLog(entry.level))
35
+ return;
36
+ // Structured stderr output for MCP transport
37
+ const prefix = entry.corrId ? `[${entry.corrId}]` : "";
38
+ const actionTag = entry.action ? ` [${entry.action}]` : "";
39
+ const dataSuffix = entry.data ? ` ${JSON.stringify(entry.data)}` : "";
40
+ const method = entry.level === "error"
41
+ ? "error"
42
+ : entry.level === "warn"
43
+ ? "warn"
44
+ : "error"; // MCP uses stderr for all logging
45
+ console.error(`[Aether:${entry.level.toUpperCase()}]${prefix}${actionTag} ${entry.msg}${dataSuffix}`);
46
+ for (const fn of listeners) {
47
+ try {
48
+ fn(entry);
49
+ }
50
+ catch {
51
+ // Listener errors must not break logging
52
+ }
53
+ }
54
+ }
55
+ let idCounter = 0;
56
+ function createLogger(corrId) {
57
+ const cid = corrId ?? `req-${++idCounter}-${Date.now().toString(36)}`;
58
+ const log = (level, msg, data, action) => {
59
+ emit({
60
+ ts: new Date().toISOString(),
61
+ level,
62
+ corrId: cid,
63
+ action,
64
+ msg,
65
+ data,
66
+ });
67
+ };
68
+ return {
69
+ corrId: cid,
70
+ debug(msg, data) {
71
+ log("debug", msg, data);
72
+ },
73
+ info(msg, data) {
74
+ log("info", msg, data);
75
+ },
76
+ warn(msg, data) {
77
+ log("warn", msg, data);
78
+ },
79
+ error(msg, data) {
80
+ log("error", msg, data);
81
+ },
82
+ child(action) {
83
+ return {
84
+ corrId: cid,
85
+ debug(msg, data) {
86
+ log("debug", msg, data, action);
87
+ },
88
+ info(msg, data) {
89
+ log("info", msg, data, action);
90
+ },
91
+ warn(msg, data) {
92
+ log("warn", msg, data, action);
93
+ },
94
+ error(msg, data) {
95
+ log("error", msg, data, action);
96
+ },
97
+ child(subAction) {
98
+ return this.child(`${action}/${subAction}`);
99
+ },
100
+ };
101
+ },
102
+ };
103
+ }
104
+ // Default logger (no correlation ID)
105
+ exports.rootLogger = createLogger("root");
@@ -663,6 +663,41 @@ const Tools = [
663
663
  required: ["fields"]
664
664
  }
665
665
  },
666
+ {
667
+ name: "get_page_text",
668
+ description: "READ TOOL. Extract clean, readable page content as Markdown (or plain text). Token-cheap alternative to screenshots or full DOM dumps for reading/understanding a page. Scopes to a CSS selector when given, otherwise auto-detects the main content region.",
669
+ inputSchema: {
670
+ type: "object",
671
+ properties: {
672
+ format: { type: "string", enum: ["markdown", "text"], description: "Output format. Default markdown." },
673
+ selector: { type: "string", description: "Optional CSS selector to scope extraction to a region." },
674
+ includeLinks: { type: "boolean", description: "Render anchors as [text](href) in markdown. Default true." },
675
+ maxLength: { type: "number", description: "Max characters returned before truncation. Default 20000." }
676
+ }
677
+ }
678
+ },
679
+ {
680
+ name: "save_auth_state",
681
+ description: "SESSION TOOL. Export the current browser session (cookies + localStorage + sessionStorage) to a JSON file so a logged-in session can be reused later with load_auth_state. Avoids repeating logins.",
682
+ inputSchema: {
683
+ type: "object",
684
+ properties: {
685
+ path: { type: "string", description: "File path to write the auth state JSON. Defaults to <cwd>/.aether/auth-state.json." },
686
+ origins: { type: "array", items: { type: "string" }, description: "Optional list of origins to capture storage for. Defaults to the current origin." }
687
+ }
688
+ }
689
+ },
690
+ {
691
+ name: "load_auth_state",
692
+ description: "SESSION TOOL. Restore a previously saved session (cookies + localStorage + sessionStorage) from a JSON file written by save_auth_state. Navigate to the target site first, then load, then reload.",
693
+ inputSchema: {
694
+ type: "object",
695
+ properties: {
696
+ path: { type: "string", description: "File path to read the auth state JSON. Defaults to <cwd>/.aether/auth-state.json." },
697
+ reload: { type: "boolean", description: "Reload the active tab after restoring so storage takes effect. Default true." }
698
+ }
699
+ }
700
+ },
666
701
  {
667
702
  name: "page_snapshot",
668
703
  description: "Capture page context optimized for LLM consumption. Lightweight by default; opt into screenshots, cookies, accessibility tree, or full DOM snapshot when needed.",
@@ -1221,6 +1256,30 @@ function RegisterMcpTools(server, wsServer) {
1221
1256
  }
1222
1257
  return { content };
1223
1258
  }
1259
+ if (name === "get_page_text") {
1260
+ const result = await bridge.sendCommand("get_page_text", {
1261
+ format: a?.format,
1262
+ selector: a?.selector,
1263
+ includeLinks: a?.includeLinks,
1264
+ maxLength: a?.maxLength,
1265
+ });
1266
+ const header = `Title: ${result.title}\nURL: ${result.url}\nFormat: ${result.format} | ${result.length} chars${result.truncated ? " (truncated)" : ""}`;
1267
+ return { content: [{ type: "text", text: `${header}\n\n${result.text}` }] };
1268
+ }
1269
+ if (name === "save_auth_state") {
1270
+ const result = await bridge.sendCommand("save_auth_state", {
1271
+ path: a?.path,
1272
+ origins: a?.origins,
1273
+ });
1274
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
1275
+ }
1276
+ if (name === "load_auth_state") {
1277
+ const result = await bridge.sendCommand("load_auth_state", {
1278
+ path: a?.path,
1279
+ reload: a?.reload,
1280
+ });
1281
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
1282
+ }
1224
1283
  throw new Error(`Unknown tool: ${name}`);
1225
1284
  }
1226
1285
  catch (error) {
@@ -1,12 +1,13 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.PageSnapshotCache = void 0;
4
- const CACHE_TTL_MS = 1000;
4
+ const CACHE_TTL_MS = 5000; // 5s — safe because cache auto-invalidates on DOM mutations
5
5
  class PageSnapshotCache {
6
6
  client;
7
7
  locator;
8
8
  version = 0;
9
9
  cached = null;
10
+ lastUrl = "";
10
11
  constructor(client, locator) {
11
12
  this.client = client;
12
13
  this.locator = locator;
@@ -15,7 +16,21 @@ class PageSnapshotCache {
15
16
  invalidate(reason = "manual") {
16
17
  this.version++;
17
18
  this.cached = null;
18
- console.error(`[SnapshotCache] invalidated: ${reason}`);
19
+ }
20
+ /** Returns true if the cache is fresh enough to serve. */
21
+ isFresh() {
22
+ const c = this.cached;
23
+ return !!(c && c.version === this.version && (Date.now() - c.createdAt) <= CACHE_TTL_MS);
24
+ }
25
+ /** Get cache metadata for debugging. */
26
+ getCacheInfo() {
27
+ const c = this.cached;
28
+ return {
29
+ version: this.version,
30
+ ageMs: c ? Date.now() - c.createdAt : Infinity,
31
+ fresh: this.isFresh(),
32
+ url: c?.url ?? "",
33
+ };
19
34
  }
20
35
  async compact(params = {}) {
21
36
  const maxElements = Math.max(0, Math.min(Number(params.maxElements ?? 30), 200));