224 lines
9.9 KiB
JavaScript
224 lines
9.9 KiB
JavaScript
import { Type } from "@sinclair/typebox";
|
||
const cache = new Map();
|
||
const MAX_CACHE_SIZE = 200;
|
||
export function registerActionCacheTools(pi, deps) {
|
||
// -------------------------------------------------------------------------
|
||
// browser_action_cache
|
||
// -------------------------------------------------------------------------
|
||
pi.registerTool({
|
||
name: "browser_action_cache",
|
||
label: "Browser Action Cache",
|
||
description: "Manage the action cache that maps page structure + intent → resolved selectors. " +
|
||
"Cache reduces token cost on repeat visits to same pages. " +
|
||
"Actions: 'stats' (show cache metrics), 'get' (lookup cached selector), " +
|
||
"'put' (store a selector mapping), 'clear' (flush cache).",
|
||
parameters: Type.Object({
|
||
action: Type.String({
|
||
description: "Cache action: 'stats', 'get', 'put', or 'clear'.",
|
||
}),
|
||
intent: Type.Optional(Type.String({
|
||
description: "Semantic intent key (for get/put). E.g., 'submit_form', 'close_dialog'.",
|
||
})),
|
||
selector: Type.Optional(Type.String({ description: "CSS selector to cache (for put)." })),
|
||
score: Type.Optional(Type.Number({
|
||
description: "Confidence score 0–1 for the cached selector (for put).",
|
||
})),
|
||
}),
|
||
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
||
try {
|
||
const { page: p } = await deps.ensureBrowser();
|
||
const url = p.url();
|
||
switch (params.action) {
|
||
case "stats": {
|
||
const entries = [...cache.values()];
|
||
const totalHits = entries.reduce((sum, e) => sum + e.hitCount, 0);
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text",
|
||
text: `Action cache: ${cache.size} entries, ${totalHits} total hits\nMax size: ${MAX_CACHE_SIZE}`,
|
||
},
|
||
],
|
||
details: {
|
||
size: cache.size,
|
||
maxSize: MAX_CACHE_SIZE,
|
||
totalHits,
|
||
entries: entries.map((e) => ({
|
||
url: e.url,
|
||
selector: e.selector,
|
||
hitCount: e.hitCount,
|
||
score: e.score,
|
||
})),
|
||
},
|
||
};
|
||
}
|
||
case "get": {
|
||
if (!params.intent) {
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text",
|
||
text: "Intent parameter required for 'get' action.",
|
||
},
|
||
],
|
||
details: { error: "missing_intent" },
|
||
isError: true,
|
||
};
|
||
}
|
||
const domHash = await computeDomHash(p);
|
||
const key = buildCacheKey(url, domHash, params.intent);
|
||
const entry = cache.get(key);
|
||
if (!entry) {
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text",
|
||
text: `Cache miss for intent "${params.intent}" on ${url}`,
|
||
},
|
||
],
|
||
details: { hit: false, intent: params.intent, url },
|
||
};
|
||
}
|
||
// Validate the cached selector still exists
|
||
const exists = await p
|
||
.locator(entry.selector)
|
||
.first()
|
||
.isVisible()
|
||
.catch(() => false);
|
||
if (!exists) {
|
||
cache.delete(key);
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text",
|
||
text: `Cache entry stale (selector no longer visible): ${entry.selector}`,
|
||
},
|
||
],
|
||
details: { hit: false, stale: true, selector: entry.selector },
|
||
};
|
||
}
|
||
entry.hitCount++;
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text",
|
||
text: `Cache hit: "${params.intent}" → ${entry.selector} (score: ${entry.score}, hits: ${entry.hitCount})`,
|
||
},
|
||
],
|
||
details: { hit: true, ...entry },
|
||
};
|
||
}
|
||
case "put": {
|
||
if (!params.intent || !params.selector) {
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text",
|
||
text: "Intent and selector parameters required for 'put' action.",
|
||
},
|
||
],
|
||
details: { error: "missing_params" },
|
||
isError: true,
|
||
};
|
||
}
|
||
const domHash = await computeDomHash(p);
|
||
const key = buildCacheKey(url, domHash, params.intent);
|
||
// Evict oldest entries if at capacity
|
||
if (cache.size >= MAX_CACHE_SIZE && !cache.has(key)) {
|
||
const oldestKey = [...cache.entries()].sort(([, a], [, b]) => a.timestamp - b.timestamp)[0]?.[0];
|
||
if (oldestKey)
|
||
cache.delete(oldestKey);
|
||
}
|
||
const entry = {
|
||
selector: params.selector,
|
||
score: params.score ?? 1.0,
|
||
url,
|
||
domHash,
|
||
timestamp: Date.now(),
|
||
hitCount: 0,
|
||
};
|
||
cache.set(key, entry);
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text",
|
||
text: `Cached: "${params.intent}" → ${params.selector} (cache size: ${cache.size})`,
|
||
},
|
||
],
|
||
details: { stored: true, key, ...entry, cacheSize: cache.size },
|
||
};
|
||
}
|
||
case "clear": {
|
||
const size = cache.size;
|
||
cache.clear();
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text",
|
||
text: `Action cache cleared (${size} entries removed).`,
|
||
},
|
||
],
|
||
details: { cleared: size },
|
||
};
|
||
}
|
||
default:
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text",
|
||
text: `Unknown action: ${params.action}. Use 'stats', 'get', 'put', or 'clear'.`,
|
||
},
|
||
],
|
||
details: { error: "unknown_action" },
|
||
isError: true,
|
||
};
|
||
}
|
||
}
|
||
catch (err) {
|
||
return {
|
||
content: [
|
||
{ type: "text", text: `Action cache error: ${err.message}` },
|
||
],
|
||
details: { error: err.message },
|
||
isError: true,
|
||
};
|
||
}
|
||
},
|
||
});
|
||
}
|
||
function buildCacheKey(url, domHash, intent) {
|
||
// Normalize URL — strip hash and query params for broader matching
|
||
let normalized;
|
||
try {
|
||
const u = new URL(url);
|
||
normalized = `${u.origin}${u.pathname}`;
|
||
}
|
||
catch {
|
||
normalized = url;
|
||
}
|
||
return `${normalized}|${domHash}|${intent}`;
|
||
}
|
||
async function computeDomHash(page) {
|
||
try {
|
||
return await page.evaluate(() => {
|
||
// Structural hash based on element count + tag distribution
|
||
const tags = new Map();
|
||
const all = document.querySelectorAll("*");
|
||
for (const el of all) {
|
||
const tag = el.tagName;
|
||
tags.set(tag, (tags.get(tag) ?? 0) + 1);
|
||
}
|
||
const entries = [...tags.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
||
const str = entries.map(([t, c]) => `${t}:${c}`).join("|");
|
||
// Simple hash
|
||
let h = 5381;
|
||
for (let i = 0; i < str.length; i++) {
|
||
h = ((h << 5) - h + str.charCodeAt(i)) | 0;
|
||
}
|
||
return (h >>> 0).toString(16);
|
||
});
|
||
}
|
||
catch {
|
||
return "unknown";
|
||
}
|
||
}
|