aether-mcp-server 2.1.0 → 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/bridge/debugging.js +133 -0
- package/dist/bridge/inspection.js +302 -0
- package/dist/bridge/interaction.js +586 -0
- package/dist/bridge/navigation.js +146 -0
- package/dist/bridge/session.js +287 -0
- package/dist/cdp-bridge.js +652 -2306
- package/dist/cdp-client.js +226 -359
- package/dist/eval-scripts.js +1024 -0
- package/dist/index.js +16 -28
- package/dist/locator-engine.js +21 -187
- package/dist/logger.js +105 -0
- package/dist/page-snapshot-cache.js +17 -2
- package/dist/types.js +267 -0
- package/package.json +1 -1
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
|
|
10
|
-
const
|
|
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
|
-
|
|
12
|
+
log.info("Shutting down...");
|
|
14
13
|
const client = (0, cdp_client_1.getCdpClient)();
|
|
15
14
|
if (client.isConnected()) {
|
|
16
|
-
|
|
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
|
-
|
|
24
|
+
log.error("Uncaught Exception", { error: String(err) });
|
|
26
25
|
shutdown();
|
|
27
26
|
});
|
|
28
27
|
process.on("unhandledRejection", (reason, promise) => {
|
|
29
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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-
|
|
55
|
-
version: "2.
|
|
42
|
+
name: "aether-mcp-server",
|
|
43
|
+
version: "2.1.0",
|
|
56
44
|
}, {
|
|
57
45
|
capabilities: {
|
|
58
46
|
tools: {},
|
|
59
47
|
},
|
|
60
48
|
});
|
|
61
|
-
//
|
|
62
|
-
(0, mcp_server_1.RegisterMcpTools)(server
|
|
63
|
-
//
|
|
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
|
-
|
|
54
|
+
log.info("MCP Server connected via Stdio. Ready.");
|
|
67
55
|
}
|
|
68
56
|
main().catch((err) => {
|
|
69
|
-
|
|
57
|
+
log.error("Fatal Error", { error: String(err) });
|
|
70
58
|
process.exit(1);
|
|
71
59
|
});
|
package/dist/locator-engine.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.LocatorEngine = void 0;
|
|
4
|
-
const element_collector_1 = require("./element-collector");
|
|
5
4
|
const DEFAULT_TIMEOUT = 7000;
|
|
6
5
|
class LocatorEngine {
|
|
7
6
|
client;
|
|
@@ -10,15 +9,12 @@ class LocatorEngine {
|
|
|
10
9
|
}
|
|
11
10
|
async list(maxElements = 50, includeText = true, withOverlay = false) {
|
|
12
11
|
const capped = Math.max(0, Math.min(Number(maxElements || 50), 200));
|
|
13
|
-
const result = await this.client.evaluate(
|
|
14
|
-
target: "",
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
mode: "list",
|
|
20
|
-
}));
|
|
21
|
-
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);
|
|
22
18
|
if (withOverlay && candidates.length > 0) {
|
|
23
19
|
await this.client.getInteractiveElements(true).catch(() => ({ elements: [], somInjected: false }));
|
|
24
20
|
}
|
|
@@ -35,14 +31,11 @@ class LocatorEngine {
|
|
|
35
31
|
return { success: false, message: "target, role, or selector required" };
|
|
36
32
|
}
|
|
37
33
|
while (Date.now() - started < timeout) {
|
|
38
|
-
const
|
|
39
|
-
target,
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
includeText: true,
|
|
44
|
-
mode: "resolve",
|
|
45
|
-
})).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);
|
|
46
39
|
const candidates = normalizeCandidates(result?.candidates);
|
|
47
40
|
const best = candidates[0];
|
|
48
41
|
if (best && (selector || best.score > 0 || role)) {
|
|
@@ -82,173 +75,14 @@ function normalizeCandidates(value) {
|
|
|
82
75
|
function sleep(ms) {
|
|
83
76
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
84
77
|
}
|
|
85
|
-
function
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const interactiveSelector = [
|
|
96
|
-
'a[href]', 'button', 'input:not([type="hidden"])', 'select', 'textarea',
|
|
97
|
-
'[onclick]', '[role]', '[tabindex]:not([tabindex="-1"])', 'label', 'summary',
|
|
98
|
-
'[contenteditable="true"]', '[aria-label]', '[placeholder]'
|
|
99
|
-
].join(', ');
|
|
100
|
-
|
|
101
|
-
${element_collector_1.SHARED_DOM_HELPERS}
|
|
102
|
-
|
|
103
|
-
// Thin aliases so the rest of this resolver reads naturally while the
|
|
104
|
-
// implementations stay shared with every other collector.
|
|
105
|
-
const norm = aetherNorm;
|
|
106
|
-
const visible = aetherVisible;
|
|
107
|
-
const cssPath = aetherStableSelector;
|
|
108
|
-
const inferRole = aetherImplicitRole;
|
|
109
|
-
const labelFor = aetherLabelFor;
|
|
110
|
-
const textFor = aetherAccessibleName;
|
|
111
|
-
|
|
112
|
-
function xpath(el) {
|
|
113
|
-
const parts = [];
|
|
114
|
-
let node = el;
|
|
115
|
-
while (node && node.nodeType === Node.ELEMENT_NODE) {
|
|
116
|
-
let index = 1;
|
|
117
|
-
let sibling = node.previousElementSibling;
|
|
118
|
-
while (sibling) {
|
|
119
|
-
if (sibling.nodeName === node.nodeName) index++;
|
|
120
|
-
sibling = sibling.previousElementSibling;
|
|
121
|
-
}
|
|
122
|
-
parts.unshift(node.nodeName.toLowerCase() + '[' + index + ']');
|
|
123
|
-
node = node.parentElement;
|
|
124
|
-
}
|
|
125
|
-
return '/' + parts.join('/');
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function scoreField(field, value, exact, includes) {
|
|
129
|
-
const text = norm(value);
|
|
130
|
-
const lower = text.toLowerCase();
|
|
131
|
-
if (!targetLower) return { score: 0, by: '' };
|
|
132
|
-
if (lower === targetLower) return { score: exact, by: field + ':exact' };
|
|
133
|
-
if (lower.includes(targetLower)) return { score: includes, by: field + ':contains' };
|
|
134
|
-
if (targetLower.includes(lower) && lower.length >= 3) return { score: Math.max(1, includes - 1), by: field + ':contained_by_target' };
|
|
135
|
-
return { score: 0, by: '' };
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function scoreCandidate(item) {
|
|
139
|
-
let score = 0;
|
|
140
|
-
let matchedBy = '';
|
|
141
|
-
const fields = [
|
|
142
|
-
['selector', item.selector, 13, 10],
|
|
143
|
-
['xpath', item.xpath, 11, 8],
|
|
144
|
-
['name', item.name, 12, 10],
|
|
145
|
-
['label', item.label, 12, 10],
|
|
146
|
-
['placeholder', item.placeholder, 11, 9],
|
|
147
|
-
['text', item.text, 10, 8],
|
|
148
|
-
['title', item.title, 8, 6],
|
|
149
|
-
['value', item.value, 7, 5]
|
|
150
|
-
];
|
|
151
|
-
for (const field of fields) {
|
|
152
|
-
const match = scoreField(field[0], field[1], field[2], field[3]);
|
|
153
|
-
if (match.score > score) {
|
|
154
|
-
score = match.score;
|
|
155
|
-
matchedBy = match.by;
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
if (roleHint) {
|
|
159
|
-
if (item.role === roleHint) score += 5;
|
|
160
|
-
else if (roleHint === 'textbox' && ['input', 'textarea'].includes(item.tag)) score += 3;
|
|
161
|
-
else score -= 3;
|
|
162
|
-
}
|
|
163
|
-
if (!targetLower && roleHint && item.role === roleHint) {
|
|
164
|
-
matchedBy = 'role';
|
|
165
|
-
score = Math.max(score, 5);
|
|
166
|
-
}
|
|
167
|
-
if (item.scope !== 'document') score += 1;
|
|
168
|
-
item.score = score;
|
|
169
|
-
item.matchedBy = matchedBy || (selectorHint ? 'selector' : '');
|
|
170
|
-
return item;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function collectFromRoot(root, framePath, frameOffset, shadowDepth, scope, out) {
|
|
174
|
-
let nodes = [];
|
|
175
|
-
try {
|
|
176
|
-
if (selectorHint) {
|
|
177
|
-
nodes = Array.from(root.querySelectorAll(selectorHint));
|
|
178
|
-
} else {
|
|
179
|
-
nodes = Array.from(root.querySelectorAll(interactiveSelector));
|
|
180
|
-
}
|
|
181
|
-
} catch {
|
|
182
|
-
nodes = [];
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
for (const el of nodes) {
|
|
186
|
-
if (!visible(el)) continue;
|
|
187
|
-
const rect = el.getBoundingClientRect();
|
|
188
|
-
const selector = cssPath(el);
|
|
189
|
-
const role = inferRole(el);
|
|
190
|
-
const label = labelFor(el);
|
|
191
|
-
const item = {
|
|
192
|
-
ref: '',
|
|
193
|
-
selector,
|
|
194
|
-
xpath: xpath(el),
|
|
195
|
-
scope,
|
|
196
|
-
framePath: framePath.slice(),
|
|
197
|
-
shadowDepth,
|
|
198
|
-
tag: el.tagName.toLowerCase(),
|
|
199
|
-
role,
|
|
200
|
-
type: el.getAttribute('type') || '',
|
|
201
|
-
name: norm(el.getAttribute('aria-label') || label || textFor(el) || el.getAttribute('name')),
|
|
202
|
-
text: includeText ? norm(el.innerText || el.textContent).substring(0, 180) : '',
|
|
203
|
-
label,
|
|
204
|
-
placeholder: norm(el.getAttribute('placeholder')),
|
|
205
|
-
title: norm(el.getAttribute('title')),
|
|
206
|
-
value: norm(el.getAttribute('value')),
|
|
207
|
-
score: 0,
|
|
208
|
-
matchedBy: '',
|
|
209
|
-
bounds: {
|
|
210
|
-
x: Math.round(frameOffset.x + rect.left),
|
|
211
|
-
y: Math.round(frameOffset.y + rect.top),
|
|
212
|
-
width: Math.round(rect.width),
|
|
213
|
-
height: Math.round(rect.height)
|
|
214
|
-
}
|
|
215
|
-
};
|
|
216
|
-
item.ref = item.scope === 'document' && item.framePath.length === 0 && item.shadowDepth === 0
|
|
217
|
-
? 'css:' + item.selector
|
|
218
|
-
: 'point:' + Math.round(item.bounds.x + item.bounds.width / 2) + ',' + Math.round(item.bounds.y + item.bounds.height / 2);
|
|
219
|
-
out.push(scoreCandidate(item));
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
const all = [];
|
|
223
|
-
try {
|
|
224
|
-
all.push(...Array.from(root.querySelectorAll('*')));
|
|
225
|
-
} catch {}
|
|
226
|
-
|
|
227
|
-
for (const el of all) {
|
|
228
|
-
if (el.shadowRoot) {
|
|
229
|
-
collectFromRoot(el.shadowRoot, framePath, frameOffset, shadowDepth + 1, 'shadow', out);
|
|
230
|
-
}
|
|
231
|
-
if (el.tagName && el.tagName.toLowerCase() === 'iframe') {
|
|
232
|
-
try {
|
|
233
|
-
const doc = el.contentDocument;
|
|
234
|
-
if (!doc) continue;
|
|
235
|
-
const rect = el.getBoundingClientRect();
|
|
236
|
-
collectFromRoot(doc, framePath.concat([Array.from(el.ownerDocument.querySelectorAll('iframe')).indexOf(el)]), {
|
|
237
|
-
x: frameOffset.x + rect.left,
|
|
238
|
-
y: frameOffset.y + rect.top
|
|
239
|
-
}, shadowDepth, 'frame', out);
|
|
240
|
-
} catch {}
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
const candidates = [];
|
|
246
|
-
collectFromRoot(document, [], { x: 0, y: 0 }, 0, 'document', candidates);
|
|
247
|
-
candidates.sort((a, b) => {
|
|
248
|
-
if (mode === 'list') return (a.bounds.y - b.bounds.y) || (a.bounds.x - b.bounds.x);
|
|
249
|
-
return b.score - a.score || a.bounds.y - b.bounds.y || a.bounds.x - b.bounds.x;
|
|
250
|
-
});
|
|
251
|
-
return { candidates: candidates.slice(0, maxCandidates) };
|
|
252
|
-
})()
|
|
253
|
-
`;
|
|
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;
|
|
254
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");
|
|
@@ -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 =
|
|
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
|
-
|
|
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));
|