@weavetab/plugin-realtime 0.0.0-stage → 0.2.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.
- package/LICENSE +202 -0
- package/README.md +113 -2
- package/dist/autopilot.d.ts +18 -0
- package/dist/autopilot.js +171 -0
- package/dist/canvas.d.ts +5 -0
- package/dist/canvas.js +159 -0
- package/dist/causal.d.ts +1 -0
- package/dist/causal.js +72 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +135 -0
- package/dist/page.d.ts +3 -0
- package/dist/page.js +33 -0
- package/dist/reflex.d.ts +29 -0
- package/dist/reflex.js +163 -0
- package/dist/surface.d.ts +19 -0
- package/dist/surface.js +59 -0
- package/package.json +51 -4
- package/weavetab.json +158 -0
package/dist/canvas.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { run } from "./page.js";
|
|
2
|
+
// recorder hooks CanvasRenderingContext2D.prototype and window.requestAnimationFrame.
|
|
3
|
+
// draws are only kept while a rAF callback is running, that is what defines a frame.
|
|
4
|
+
const SPY = `(() => {
|
|
5
|
+
if (window.__wt_cv) return 1;
|
|
6
|
+
const cv = window.__wt_cv = { frames: [], n: 0, cur: null, orig: {}, canvases: [], bg: 0, raf: window.requestAnimationFrame };
|
|
7
|
+
const P = CanvasRenderingContext2D.prototype;
|
|
8
|
+
|
|
9
|
+
const rec = (ctx, kind, key, x, y, w, h) => {
|
|
10
|
+
const m = ctx.getTransform();
|
|
11
|
+
const x1 = m.a * x + m.c * y + m.e, y1 = m.b * x + m.d * y + m.f;
|
|
12
|
+
const x2 = m.a * (x + w) + m.c * (y + h) + m.e, y2 = m.b * (x + w) + m.d * (y + h) + m.f;
|
|
13
|
+
let ci = cv.canvases.indexOf(ctx.canvas);
|
|
14
|
+
if (ci < 0) ci = cv.canvases.push(ctx.canvas) - 1;
|
|
15
|
+
const ww = Math.abs(x2 - x1), hh = Math.abs(y2 - y1);
|
|
16
|
+
cv.cur.push({ sig: kind + '|' + Math.round(ww) + 'x' + Math.round(hh) + '|' + key,
|
|
17
|
+
x: Math.min(x1, x2), y: Math.min(y1, y2), w: ww, h: hh, c: ci });
|
|
18
|
+
};
|
|
19
|
+
const styleKey = (s) => typeof s === 'string' ? s : 'grad';
|
|
20
|
+
const imgKey = (im) => {
|
|
21
|
+
const s = im.currentSrc || im.src;
|
|
22
|
+
return s ? s.split('?')[0].split('/').pop() : (im.id || im.tagName || 'bmp');
|
|
23
|
+
};
|
|
24
|
+
const wrap = (name, fn) => {
|
|
25
|
+
const o = P[name];
|
|
26
|
+
cv.orig[name] = o;
|
|
27
|
+
P[name] = function () {
|
|
28
|
+
const r = o.apply(this, arguments);
|
|
29
|
+
// the spy must never break the game's own draw call
|
|
30
|
+
try { if (cv.cur && this.canvas.isConnected && cv.cur.length < 2000) fn(this, arguments); } catch (e) {}
|
|
31
|
+
return r;
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
wrap('fillRect', (c, a) => rec(c, 'rect', styleKey(c.fillStyle), a[0], a[1], a[2], a[3]));
|
|
36
|
+
wrap('strokeRect', (c, a) => rec(c, 'frame', styleKey(c.strokeStyle), a[0], a[1], a[2], a[3]));
|
|
37
|
+
wrap('drawImage', (c, a) => {
|
|
38
|
+
const im = a[0], n = a.length;
|
|
39
|
+
if (n >= 9) rec(c, 'img', imgKey(im) + '@' + a[1] + ',' + a[2], a[5], a[6], a[7], a[8]);
|
|
40
|
+
else if (n >= 5) rec(c, 'img', imgKey(im), a[1], a[2], a[3], a[4]);
|
|
41
|
+
else rec(c, 'img', imgKey(im), a[1], a[2], im.naturalWidth || im.videoWidth || im.width || 0, im.naturalHeight || im.videoHeight || im.height || 0);
|
|
42
|
+
});
|
|
43
|
+
const text = (c, a) => {
|
|
44
|
+
const m = c.measureText(a[0]);
|
|
45
|
+
rec(c, 'text', String(a[0]).slice(0, 24), a[1], a[2] - m.actualBoundingBoxAscent, m.width, m.actualBoundingBoxAscent + m.actualBoundingBoxDescent);
|
|
46
|
+
};
|
|
47
|
+
wrap('fillText', text);
|
|
48
|
+
wrap('strokeText', text);
|
|
49
|
+
wrap('beginPath', (c) => { c.__wtp = []; });
|
|
50
|
+
wrap('arc', (c, a) => {
|
|
51
|
+
if (Math.abs(a[4] - a[3]) >= 6.2) (c.__wtp || (c.__wtp = [])).push([a[0], a[1], a[2]]);
|
|
52
|
+
});
|
|
53
|
+
const commit = (style) => (c) => {
|
|
54
|
+
for (const p of (c.__wtp || [])) rec(c, 'arc', styleKey(c[style]), p[0] - p[2], p[1] - p[2], p[2] * 2, p[2] * 2);
|
|
55
|
+
c.__wtp = [];
|
|
56
|
+
};
|
|
57
|
+
wrap('fill', commit('fillStyle'));
|
|
58
|
+
wrap('stroke', commit('strokeStyle'));
|
|
59
|
+
|
|
60
|
+
window.requestAnimationFrame = function (cb) {
|
|
61
|
+
return cv.raf.call(window, function (ts) {
|
|
62
|
+
cv.cur = [];
|
|
63
|
+
try { return cb(ts); } finally {
|
|
64
|
+
cv.frames.push({ t: ts, d: cv.cur });
|
|
65
|
+
if (cv.frames.length > 16) cv.frames.shift();
|
|
66
|
+
cv.cur = null;
|
|
67
|
+
cv.n++;
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
cv.track = (sig, depth) => {
|
|
73
|
+
const fr = cv.frames;
|
|
74
|
+
if (!fr.length) return [];
|
|
75
|
+
const last = fr[fr.length - 1].d.filter((d) => d.sig === sig);
|
|
76
|
+
if (last.length > 200) return last.map((d) => Object.assign({ vx: 0, vy: 0 }, d));
|
|
77
|
+
const prevs = fr.slice(Math.max(0, fr.length - 1 - depth), -1).reverse().map((f) => f.d.filter((d) => d.sig === sig));
|
|
78
|
+
return last.map((d) => {
|
|
79
|
+
let cur = d, steps = 0;
|
|
80
|
+
for (const p of prevs) {
|
|
81
|
+
let best = null, bd = 1e9;
|
|
82
|
+
for (const q of p) {
|
|
83
|
+
const dd = (q.x - cur.x) * (q.x - cur.x) + (q.y - cur.y) * (q.y - cur.y);
|
|
84
|
+
if (dd < bd) { bd = dd; best = q; }
|
|
85
|
+
}
|
|
86
|
+
if (!best || bd > 40000) break;
|
|
87
|
+
cur = best; steps++;
|
|
88
|
+
}
|
|
89
|
+
return Object.assign({}, d, { vx: steps ? (d.x - cur.x) / steps : 0, vy: steps ? (d.y - cur.y) / steps : 0 });
|
|
90
|
+
});
|
|
91
|
+
};
|
|
92
|
+
return 0;
|
|
93
|
+
})()`;
|
|
94
|
+
const WAIT = (frames) => `new Promise((res) => {
|
|
95
|
+
const cv = window.__wt_cv, start = cv.n, t0 = performance.now();
|
|
96
|
+
const tick = () => (cv.n - start >= ${frames} || performance.now() - t0 > 3000) ? res(cv.n - start) : setTimeout(tick, 8);
|
|
97
|
+
tick();
|
|
98
|
+
})`;
|
|
99
|
+
const REPORT = `(() => {
|
|
100
|
+
const cv = window.__wt_cv;
|
|
101
|
+
const fr = cv.frames, last = fr[fr.length - 1];
|
|
102
|
+
const r1 = (v) => Math.round(v * 10) / 10;
|
|
103
|
+
const dt = fr.length > 1 ? fr[fr.length - 1].t - fr[0].t : 0;
|
|
104
|
+
const cans = [];
|
|
105
|
+
const info = (i) => {
|
|
106
|
+
let o = cans.find((c) => c.i === i);
|
|
107
|
+
if (!o) {
|
|
108
|
+
const el = cv.canvases[i], r = el.getBoundingClientRect();
|
|
109
|
+
o = { i, id: el.id || null, w: el.width, h: el.height, left: r.left, top: r.top, sx: r.width / el.width, sy: r.height / el.height };
|
|
110
|
+
cans.push(o);
|
|
111
|
+
}
|
|
112
|
+
return o;
|
|
113
|
+
};
|
|
114
|
+
const sigs = {};
|
|
115
|
+
for (const d of last.d) (sigs[d.sig] || (sigs[d.sig] = [])).push(d);
|
|
116
|
+
const ents = [], groups = [];
|
|
117
|
+
let bg = 0;
|
|
118
|
+
for (const sig of Object.keys(sigs).slice(0, 200)) {
|
|
119
|
+
const inst = sigs[sig];
|
|
120
|
+
const kind = sig.split('|')[0];
|
|
121
|
+
const tr = cv.track(sig, 5);
|
|
122
|
+
if (inst.length > 6) {
|
|
123
|
+
groups.push({ sig, count: inst.length, moving: tr.filter((t) => t.vx || t.vy).length });
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
for (const t of tr) {
|
|
127
|
+
const cn = info(t.c);
|
|
128
|
+
if ((kind === 'rect') && t.w >= cn.w * 0.9 && t.h >= cn.h * 0.9) { bg++; continue; }
|
|
129
|
+
ents.push({ sig, canvas: t.c, x: r1(t.x), y: r1(t.y), w: r1(t.w), h: r1(t.h), vx: r1(t.vx), vy: r1(t.vy) });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
ents.sort((a, b) => (Math.abs(b.vx) + Math.abs(b.vy)) - (Math.abs(a.vx) + Math.abs(a.vy)));
|
|
133
|
+
return { frames: fr.length, fps: dt ? Math.round((fr.length - 1) * 1000 / dt) : null, canvases: cans,
|
|
134
|
+
entities: ents.slice(0, 40), groups: groups.slice(0, 20), background_fills: bg, truncated: ents.length > 40 };
|
|
135
|
+
})()`;
|
|
136
|
+
const OFF = `(() => {
|
|
137
|
+
const cv = window.__wt_cv;
|
|
138
|
+
if (!cv) return 0;
|
|
139
|
+
const P = CanvasRenderingContext2D.prototype;
|
|
140
|
+
for (const k of Object.keys(cv.orig)) P[k] = cv.orig[k];
|
|
141
|
+
window.requestAnimationFrame = cv.raf;
|
|
142
|
+
delete window.__wt_cv;
|
|
143
|
+
return 1;
|
|
144
|
+
})()`;
|
|
145
|
+
export async function canvasTool(sess, a) {
|
|
146
|
+
if (a.action === "detach")
|
|
147
|
+
return { detached: Boolean(await run(sess, OFF)) };
|
|
148
|
+
const fresh = (await run(sess, SPY)) === 0;
|
|
149
|
+
if (a.action === "attach")
|
|
150
|
+
return { attached: true, already: !fresh };
|
|
151
|
+
const want = Math.min(Math.max(a.frames ?? 6, 2), 15);
|
|
152
|
+
const got = await run(sess, WAIT(want), true);
|
|
153
|
+
if (!got)
|
|
154
|
+
throw new Error("no frames seen: tab hidden, or the game does not use requestAnimationFrame");
|
|
155
|
+
return run(sess, REPORT);
|
|
156
|
+
}
|
|
157
|
+
export function canvasSnap(sess) {
|
|
158
|
+
return run(sess, `window.__wt_cv && window.__wt_cv.frames.length ? ${REPORT} : null`);
|
|
159
|
+
}
|
package/dist/causal.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function tagMap(sess: any, res: any, winMs: number): Promise<any>;
|
package/dist/causal.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { run } from "./page.js";
|
|
2
|
+
const LINE = /^(\s*)\[w:(\d+)\]/;
|
|
3
|
+
function probeIds(ids, winMs) {
|
|
4
|
+
return `((ids, win) => {
|
|
5
|
+
const rt = window.__wt_rt;
|
|
6
|
+
if (!rt) return [];
|
|
7
|
+
const now = performance.now();
|
|
8
|
+
const clk = rt.click && now - rt.click.t < 15000 ? rt.click : null;
|
|
9
|
+
const out = [];
|
|
10
|
+
for (const id of ids) {
|
|
11
|
+
const el = document.querySelector('[data-wt-id="' + id + '"]');
|
|
12
|
+
if (!el) continue;
|
|
13
|
+
let b;
|
|
14
|
+
for (let n = el; n && b === undefined; n = n.parentElement) b = rt.born.get(n);
|
|
15
|
+
if (b === undefined || now - b > win) continue;
|
|
16
|
+
let dist = null;
|
|
17
|
+
if (clk) {
|
|
18
|
+
const r = el.getBoundingClientRect();
|
|
19
|
+
dist = Math.round(Math.hypot(r.left + r.width / 2 - clk.x, r.top + r.height / 2 - clk.y));
|
|
20
|
+
}
|
|
21
|
+
out.push([id, Math.round(now - b), dist]);
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
})(${JSON.stringify(ids)}, ${winMs})`;
|
|
25
|
+
}
|
|
26
|
+
async function tagText(sess, text, winMs) {
|
|
27
|
+
const lines = text.split("\n");
|
|
28
|
+
const ids = new Set();
|
|
29
|
+
for (const ln of lines) {
|
|
30
|
+
const m = LINE.exec(ln);
|
|
31
|
+
if (m)
|
|
32
|
+
ids.add(m[2]);
|
|
33
|
+
}
|
|
34
|
+
if (!ids.size)
|
|
35
|
+
return text;
|
|
36
|
+
const hits = await run(sess, probeIds([...ids], winMs));
|
|
37
|
+
if (!hits.length)
|
|
38
|
+
return text;
|
|
39
|
+
const byId = new Map(hits.map((h) => [h[0], h]));
|
|
40
|
+
return lines
|
|
41
|
+
.map((ln) => {
|
|
42
|
+
const m = LINE.exec(ln);
|
|
43
|
+
const h = m && byId.get(m[2]);
|
|
44
|
+
if (!h)
|
|
45
|
+
return ln;
|
|
46
|
+
return `${ln} {new ${h[1]}ms${h[2] == null ? "" : `, ${h[2]}px from click`}}`;
|
|
47
|
+
})
|
|
48
|
+
.join("\n");
|
|
49
|
+
}
|
|
50
|
+
// returns undefined when nothing changed so the pipeline keeps the original result
|
|
51
|
+
export async function tagMap(sess, res, winMs) {
|
|
52
|
+
if (typeof res === "string") {
|
|
53
|
+
const t = await tagText(sess, res, winMs);
|
|
54
|
+
return t === res ? undefined : t;
|
|
55
|
+
}
|
|
56
|
+
if (!Array.isArray(res?.content))
|
|
57
|
+
return;
|
|
58
|
+
let touched = false;
|
|
59
|
+
const content = [];
|
|
60
|
+
for (const blk of res.content) {
|
|
61
|
+
if (blk?.type === "text" && typeof blk.text === "string") {
|
|
62
|
+
const t = await tagText(sess, blk.text, winMs);
|
|
63
|
+
if (t !== blk.text)
|
|
64
|
+
touched = true;
|
|
65
|
+
content.push({ ...blk, text: t });
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
content.push(blk);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return touched ? { ...res, content } : undefined;
|
|
72
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { definePlugin, defineTool, definePrompt, defineResource, z } from "@weavetab/sdk";
|
|
2
|
+
import { arm, sessOf } from "./page.js";
|
|
3
|
+
import { tagMap } from "./causal.js";
|
|
4
|
+
import { canvasTool } from "./canvas.js";
|
|
5
|
+
import { reflexTool, statusTool } from "./reflex.js";
|
|
6
|
+
import { autopilotTool } from "./autopilot.js";
|
|
7
|
+
import { readFeed } from "./surface.js";
|
|
8
|
+
let winMs = 4000;
|
|
9
|
+
const asText = (v) => ({ content: [{ type: "text", text: JSON.stringify(v, null, 2) }] });
|
|
10
|
+
const plugin = definePlugin({
|
|
11
|
+
name: "@weavetab/plugin-realtime",
|
|
12
|
+
version: "0.2.0",
|
|
13
|
+
description: "Causal tags for browser_map, in-page reflex rules, canvas entity sampling, bounded click loop.",
|
|
14
|
+
defaultConfig: { causalWindowMs: 4000 },
|
|
15
|
+
permissions: {
|
|
16
|
+
cdp: { evaluate: true, input: true, reason: "Page probe, canvas spy, 60Hz reflex loop, and trusted CDP mouse clicks." },
|
|
17
|
+
tools: { add: true, reason: "Registers realtime_canvas, realtime_reflex, realtime_autopilot and realtime_status." },
|
|
18
|
+
prompts: { add: true, reason: "Registers /realtime_watch to guide setting up 60Hz DOM or canvas reflexes." },
|
|
19
|
+
resources: { add: true, reason: "Registers @realtime_feed (realtime://feed) for live reflex, DOM, and canvas telemetry." },
|
|
20
|
+
hooks: { onBeforeAction: true, onAfterAction: true, reason: "Arms page probe and appends {new ...} causal tags to browser_map." },
|
|
21
|
+
network: { hosts: ["127.0.0.1", "localhost"], reason: "Optional local model_url in realtime_autopilot." },
|
|
22
|
+
},
|
|
23
|
+
async onBeforeAction(_tool, _args, sess) {
|
|
24
|
+
if (sess?.Runtime)
|
|
25
|
+
await arm(sess).catch(() => { }); // page may be mid-navigation, next call retries
|
|
26
|
+
},
|
|
27
|
+
async onAfterAction(tool, res, sess) {
|
|
28
|
+
if (tool !== "browser_map" || !sess?.Runtime)
|
|
29
|
+
return;
|
|
30
|
+
return tagMap(sess, res, winMs).catch(() => undefined);
|
|
31
|
+
},
|
|
32
|
+
async onLoad(ctx) {
|
|
33
|
+
winMs = Number(ctx.config?.causalWindowMs) || 4000;
|
|
34
|
+
const reg = ctx.mcp.registerTool;
|
|
35
|
+
reg(defineTool({
|
|
36
|
+
name: "realtime_canvas",
|
|
37
|
+
description: "Sample what a 2D <canvas> game draws. 'sample' watches a few frames and returns entities (signature, bbox, px/frame velocity) in canvas pixels, plus canvas position/scale to convert to viewport coords. Only sees Canvas2D draw calls made inside requestAnimationFrame; WebGL is invisible to it. 'attach' starts recording early, 'detach' restores the page.",
|
|
38
|
+
requiresSession: true,
|
|
39
|
+
schema: {
|
|
40
|
+
action: z.enum(["sample", "attach", "detach"]).default("sample"),
|
|
41
|
+
frames: z.number().int().min(2).max(15).optional().describe("frames to watch, default 6"),
|
|
42
|
+
},
|
|
43
|
+
handler: async (a, ctx) => asText(await canvasTool(sessOf(ctx), a)),
|
|
44
|
+
}));
|
|
45
|
+
reg(defineTool({
|
|
46
|
+
name: "realtime_reflex",
|
|
47
|
+
description: "Register a rule that runs inside the page every animation frame, no LLM round-trip. Trigger is either a DOM selector (optionally text / only_new) or a canvas expression over named entity signatures from realtime_canvas: names {player:'rect|20x20|#f00'}, js \"gap(E.player[0], E.cactus[0]) < 50\" (E.name = array of {x,y,w,h,vx,vy}; dist() and gap() return Infinity when an entity is missing). Action: click, click_xy, key, or js. Events are synthetic, sites that check isTrusted ignore them. Needs a visible tab.",
|
|
48
|
+
requiresSession: true,
|
|
49
|
+
schema: {
|
|
50
|
+
action: z.enum(["add", "remove", "list", "clear"]).default("add"),
|
|
51
|
+
id: z.string().optional(),
|
|
52
|
+
when: z
|
|
53
|
+
.object({
|
|
54
|
+
selector: z.string().optional(),
|
|
55
|
+
text: z.string().optional().describe("case-insensitive substring of label/text"),
|
|
56
|
+
only_new: z.boolean().optional().describe("only elements added after this rule was created"),
|
|
57
|
+
enabled: z.boolean().optional().describe("skip disabled elements, default true"),
|
|
58
|
+
canvas: z.object({ names: z.record(z.string()), js: z.string() }).optional(),
|
|
59
|
+
})
|
|
60
|
+
.optional(),
|
|
61
|
+
do: z
|
|
62
|
+
.object({
|
|
63
|
+
type: z.enum(["click", "click_xy", "key", "js"]),
|
|
64
|
+
x: z.number().optional(),
|
|
65
|
+
y: z.number().optional(),
|
|
66
|
+
key: z.string().optional(),
|
|
67
|
+
hold_ms: z.number().optional().describe("key hold time, default 40"),
|
|
68
|
+
code: z.string().optional(),
|
|
69
|
+
})
|
|
70
|
+
.optional(),
|
|
71
|
+
cooldown_ms: z.number().optional().describe("default 100"),
|
|
72
|
+
max_hits: z.number().optional().describe("default 50"),
|
|
73
|
+
ttl_ms: z.number().optional().describe("default 30000, max 600000"),
|
|
74
|
+
stop_selector: z.string().optional(),
|
|
75
|
+
},
|
|
76
|
+
handler: async (a, ctx) => asText(await reflexTool(sessOf(ctx), a)),
|
|
77
|
+
}));
|
|
78
|
+
reg(defineTool({
|
|
79
|
+
name: "realtime_autopilot",
|
|
80
|
+
description: "Bounded click loop for short UI sub-goals ('accept cookies', 'click next until Order summary shows'). Ranks visible, unoccluded clickable elements by word overlap with the goal, boosts ones that just appeared and ones inside an open dialog, and never re-clicks a button whose last click changed nothing. Trusted CDP mouse clicks. Skips delete/pay/sign-out style labels unless the goal says so. Not a planner: stops with no_match when no label overlaps the goal. Optional model_url = OpenAI-compatible chat endpoint to pick instead; falls back to the ranker on any failure.",
|
|
81
|
+
requiresSession: true,
|
|
82
|
+
schema: {
|
|
83
|
+
goal: z.string().min(3),
|
|
84
|
+
max_steps: z.number().int().min(1).max(25).optional().describe("default 8"),
|
|
85
|
+
stop_when_selector: z.string().optional(),
|
|
86
|
+
stop_when_text: z.string().optional(),
|
|
87
|
+
stop_when_url: z.string().optional().describe("regex tested against location.href"),
|
|
88
|
+
avoid_labels: z.string().optional().describe("regex of extra labels never to click"),
|
|
89
|
+
model_url: z.string().url().optional(),
|
|
90
|
+
model: z.string().optional(),
|
|
91
|
+
model_timeout_ms: z.number().optional().describe("default 1500"),
|
|
92
|
+
},
|
|
93
|
+
handler: async (a, ctx) => asText(await autopilotTool(sessOf(ctx), a)),
|
|
94
|
+
}));
|
|
95
|
+
reg(defineTool({
|
|
96
|
+
name: "realtime_status",
|
|
97
|
+
description: "Probe/canvas/reflex state for the current page. wait_ms (max 30000) blocks until every active reflex has finished, so the agent can sleep through a game or a drop-watch.",
|
|
98
|
+
requiresSession: true,
|
|
99
|
+
schema: { wait_ms: z.number().optional() },
|
|
100
|
+
handler: async (a, ctx) => asText(await statusTool(sessOf(ctx), a.wait_ms)),
|
|
101
|
+
}));
|
|
102
|
+
ctx.mcp.registerPrompt?.(definePrompt({
|
|
103
|
+
name: "realtime_watch",
|
|
104
|
+
title: "Watch & React (60Hz Reflex)",
|
|
105
|
+
description: "Set up an in-page 60Hz reflex rule or canvas game loop and wait until it finishes.",
|
|
106
|
+
arguments: {
|
|
107
|
+
goal: z.string().describe("What to watch for and trigger (e.g. 'click Claim as soon as it appears' or 'jump over green obstacles in the canvas game')"),
|
|
108
|
+
mode: z.string().optional().describe("'dom', 'canvas', or 'auto' (default 'auto')"),
|
|
109
|
+
},
|
|
110
|
+
handler: async ({ goal, mode }, sctx) => {
|
|
111
|
+
const feed = await readFeed(sessOf(sctx), winMs).catch(() => ({ active_tab: false }));
|
|
112
|
+
const m = (mode || "auto").toLowerCase();
|
|
113
|
+
return [
|
|
114
|
+
`Goal: ${goal}`,
|
|
115
|
+
`Mode: ${m}`,
|
|
116
|
+
`Live state: ${JSON.stringify(feed)}`,
|
|
117
|
+
m === "canvas"
|
|
118
|
+
? "1. Call realtime_canvas with action='sample' to inspect moving entity signatures and velocities.\n2. Arm realtime_reflex with when.canvas ({ names, js }) using gap(E.a[0], E.b[0]) or dist(E.a[0], E.b[0]).\n3. Call realtime_status with wait_ms to block until the reflex completes."
|
|
119
|
+
: "1. If targeting a 2D <canvas>, run realtime_canvas (action='sample') first to grab entity signatures; if targeting DOM changes, pick a selector/text filter (set only_new=true to ignore elements already on screen).\n2. Arm the rule with realtime_reflex (action='add').\n3. Call realtime_status with wait_ms (up to 30000) to sleep until the reflex hits max_hits or stop_selector.",
|
|
120
|
+
].join("\n\n");
|
|
121
|
+
},
|
|
122
|
+
}));
|
|
123
|
+
ctx.mcp.registerResource?.(defineResource({
|
|
124
|
+
name: "realtime_feed",
|
|
125
|
+
title: "Realtime Page Feed",
|
|
126
|
+
uri: "realtime://feed",
|
|
127
|
+
mimeType: "application/json",
|
|
128
|
+
description: "Live snapshot of active 60Hz reflex rules, recently mutated DOM elements with click distance, and tracked Canvas2D entities.",
|
|
129
|
+
read: async (_uri, _vars, sctx) => JSON.stringify(await readFeed(sessOf(sctx), winMs), null, 2),
|
|
130
|
+
}));
|
|
131
|
+
ctx.logger?.info("realtime up");
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
export default plugin;
|
|
135
|
+
export { plugin };
|
package/dist/page.d.ts
ADDED
package/dist/page.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// page-side probe shared by every tool in here. one per document, survives until navigation.
|
|
2
|
+
const PROBE = `(() => {
|
|
3
|
+
if (window.__wt_rt) return 1;
|
|
4
|
+
const rt = window.__wt_rt = { born: new WeakMap(), click: null, lastMut: 0 };
|
|
5
|
+
new MutationObserver(recs => {
|
|
6
|
+
const now = performance.now();
|
|
7
|
+
for (const r of recs) {
|
|
8
|
+
if (r.type === 'childList') {
|
|
9
|
+
for (const n of r.addedNodes) if (n.nodeType === 1 && !rt.born.has(n)) rt.born.set(n, now);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
rt.lastMut = now;
|
|
13
|
+
}).observe(document.documentElement, {
|
|
14
|
+
childList: true, subtree: true, characterData: true, attributes: true,
|
|
15
|
+
attributeFilter: ['class', 'hidden', 'aria-expanded', 'aria-hidden', 'open', 'disabled']
|
|
16
|
+
});
|
|
17
|
+
addEventListener('pointerdown', e => { rt.click = { x: e.clientX, y: e.clientY, t: performance.now() }; }, true);
|
|
18
|
+
return 0;
|
|
19
|
+
})()`;
|
|
20
|
+
export function sessOf(ctx) {
|
|
21
|
+
return ctx?.tab?.session || ctx?.session || ctx;
|
|
22
|
+
}
|
|
23
|
+
export async function run(sess, expression, awaitPromise = false) {
|
|
24
|
+
const out = await sess.Runtime.evaluate({ expression, returnByValue: true, awaitPromise });
|
|
25
|
+
if (out.exceptionDetails) {
|
|
26
|
+
const ex = out.exceptionDetails;
|
|
27
|
+
throw new Error(ex.exception?.description?.split("\n")[0] || ex.text || "page eval failed");
|
|
28
|
+
}
|
|
29
|
+
return out.result?.value;
|
|
30
|
+
}
|
|
31
|
+
export function arm(sess) {
|
|
32
|
+
return run(sess, PROBE);
|
|
33
|
+
}
|
package/dist/reflex.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
type Rule = {
|
|
2
|
+
action: "add" | "remove" | "list" | "clear";
|
|
3
|
+
id?: string;
|
|
4
|
+
when?: {
|
|
5
|
+
selector?: string;
|
|
6
|
+
text?: string;
|
|
7
|
+
only_new?: boolean;
|
|
8
|
+
enabled?: boolean;
|
|
9
|
+
canvas?: {
|
|
10
|
+
names: Record<string, string>;
|
|
11
|
+
js: string;
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
do?: {
|
|
15
|
+
type: "click" | "click_xy" | "key" | "js";
|
|
16
|
+
x?: number;
|
|
17
|
+
y?: number;
|
|
18
|
+
key?: string;
|
|
19
|
+
hold_ms?: number;
|
|
20
|
+
code?: string;
|
|
21
|
+
};
|
|
22
|
+
cooldown_ms?: number;
|
|
23
|
+
max_hits?: number;
|
|
24
|
+
ttl_ms?: number;
|
|
25
|
+
stop_selector?: string;
|
|
26
|
+
};
|
|
27
|
+
export declare function reflexTool(sess: any, a: Rule): Promise<any>;
|
|
28
|
+
export declare function statusTool(sess: any, waitMs?: number): Promise<any>;
|
|
29
|
+
export {};
|
package/dist/reflex.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { run, arm } from "./page.js";
|
|
2
|
+
import { canvasTool } from "./canvas.js";
|
|
3
|
+
// one rAF loop per page, only alive while a rule is active.
|
|
4
|
+
// it grabs the un-spied rAF so reflex ticks never show up as canvas frames.
|
|
5
|
+
const RUNTIME = `(() => {
|
|
6
|
+
if (window.__wt_rf) return 1;
|
|
7
|
+
const nativeRaf = (window.__wt_cv && window.__wt_cv.raf) || window.requestAnimationFrame;
|
|
8
|
+
const rf = window.__wt_rf = { rules: {}, on: false };
|
|
9
|
+
|
|
10
|
+
const CODES = { ' ': ['Space', 32], Enter: ['Enter', 13], Escape: ['Escape', 27], Tab: ['Tab', 9],
|
|
11
|
+
ArrowLeft: ['ArrowLeft', 37], ArrowUp: ['ArrowUp', 38], ArrowRight: ['ArrowRight', 39], ArrowDown: ['ArrowDown', 40] };
|
|
12
|
+
const press = (k, hold) => {
|
|
13
|
+
if (k === 'Space') k = ' ';
|
|
14
|
+
const c = CODES[k] || (/^[a-z]$/i.test(k) ? ['Key' + k.toUpperCase(), k.toUpperCase().charCodeAt(0)]
|
|
15
|
+
: /^[0-9]$/.test(k) ? ['Digit' + k, k.charCodeAt(0)] : [k, 0]);
|
|
16
|
+
const o = { key: k, code: c[0], keyCode: c[1], which: c[1], bubbles: true, cancelable: true };
|
|
17
|
+
const tgt = document.activeElement || document.body;
|
|
18
|
+
tgt.dispatchEvent(new KeyboardEvent('keydown', o));
|
|
19
|
+
setTimeout(() => tgt.dispatchEvent(new KeyboardEvent('keyup', o)), hold);
|
|
20
|
+
};
|
|
21
|
+
const click = (el, x, y) => {
|
|
22
|
+
if (x == null) { const b = el.getBoundingClientRect(); x = b.left + b.width / 2; y = b.top + b.height / 2; }
|
|
23
|
+
const o = { bubbles: true, cancelable: true, composed: true, clientX: x, clientY: y, view: window, button: 0 };
|
|
24
|
+
for (const t of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click'])
|
|
25
|
+
el.dispatchEvent(t.startsWith('pointer') ? new PointerEvent(t, o) : new MouseEvent(t, o));
|
|
26
|
+
};
|
|
27
|
+
const shown = (el) => {
|
|
28
|
+
const r = el.getBoundingClientRect();
|
|
29
|
+
if (!r.width || !r.height) return false;
|
|
30
|
+
const s = getComputedStyle(el);
|
|
31
|
+
return s.visibility !== 'hidden' && s.display !== 'none';
|
|
32
|
+
};
|
|
33
|
+
const isNew = (el, since) => {
|
|
34
|
+
const rt = window.__wt_rt;
|
|
35
|
+
if (!rt) return false;
|
|
36
|
+
for (let n = el; n; n = n.parentElement) { const b = rt.born.get(n); if (b !== undefined) return b >= since; }
|
|
37
|
+
return false;
|
|
38
|
+
};
|
|
39
|
+
const find = (r) => {
|
|
40
|
+
for (const el of document.querySelectorAll(r.sel)) {
|
|
41
|
+
if (r.enabled && (el.disabled || el.getAttribute('aria-disabled') === 'true')) continue;
|
|
42
|
+
if (r.text && !((el.getAttribute('aria-label') || '') + ' ' + (el.value || '') + ' ' + el.textContent).toLowerCase().includes(r.text)) continue;
|
|
43
|
+
if (r.onlyNew && !isNew(el, r.t0)) continue;
|
|
44
|
+
if (shown(el)) return el;
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
};
|
|
48
|
+
const dist = (a, b) => (a && b) ? Math.hypot(a.x + a.w / 2 - b.x - b.w / 2, a.y + a.h / 2 - b.y - b.h / 2) : Infinity;
|
|
49
|
+
const gap = (a, b) => (a && b) ? b.x - (a.x + a.w) : Infinity;
|
|
50
|
+
const E = new Proxy({}, { get: (_, k) => {
|
|
51
|
+
const cv = window.__wt_cv, sig = rf.names && rf.names[k];
|
|
52
|
+
return cv && sig ? cv.track(sig, 2) : [];
|
|
53
|
+
} });
|
|
54
|
+
|
|
55
|
+
const tick = () => {
|
|
56
|
+
const now = performance.now();
|
|
57
|
+
let live = 0;
|
|
58
|
+
for (const id in rf.rules) {
|
|
59
|
+
const r = rf.rules[id];
|
|
60
|
+
if (r.done) continue;
|
|
61
|
+
try {
|
|
62
|
+
if (now - r.t0 > r.ttl) { r.done = 'ttl'; continue; }
|
|
63
|
+
if (r.stop && document.querySelector(r.stop)) { r.done = 'stop_selector'; continue; }
|
|
64
|
+
live++;
|
|
65
|
+
if (now - r.last < r.cooldown) continue;
|
|
66
|
+
rf.names = r.names;
|
|
67
|
+
let el = null;
|
|
68
|
+
if (r.sel) { el = find(r); if (!el) continue; }
|
|
69
|
+
else if (!r.whenFn(E, dist, gap)) continue;
|
|
70
|
+
const a = r.act;
|
|
71
|
+
if (a.type === 'click') click(el);
|
|
72
|
+
else if (a.type === 'click_xy') click(document.elementFromPoint(a.x, a.y) || document.body, a.x, a.y);
|
|
73
|
+
else if (a.type === 'key') press(a.key, a.hold);
|
|
74
|
+
else r.jsFn();
|
|
75
|
+
r.hits++; r.last = now;
|
|
76
|
+
if (r.hits >= r.max) { r.done = 'max_hits'; live--; }
|
|
77
|
+
} catch (e) { r.done = 'error'; r.err = String((e && e.message) || e); }
|
|
78
|
+
}
|
|
79
|
+
if (live) nativeRaf.call(window, tick); else rf.on = false;
|
|
80
|
+
};
|
|
81
|
+
rf.add = (rule, whenFn, jsFn) => {
|
|
82
|
+
rf.rules[rule.id] = Object.assign({}, rule, { t0: performance.now(), last: -1e9, hits: 0, done: null, err: null, whenFn, jsFn });
|
|
83
|
+
if (!rf.on) { rf.on = true; nativeRaf.call(window, tick); }
|
|
84
|
+
};
|
|
85
|
+
rf.snap = () => {
|
|
86
|
+
const now = performance.now();
|
|
87
|
+
return Object.keys(rf.rules).map((id) => {
|
|
88
|
+
const r = rf.rules[id];
|
|
89
|
+
return { id, kind: r.sel ? 'dom' : 'canvas', hits: r.hits, done: r.done, error: r.err,
|
|
90
|
+
last_hit_ms_ago: r.hits ? Math.round(now - r.last) : null, age_ms: Math.round(now - r.t0) };
|
|
91
|
+
});
|
|
92
|
+
};
|
|
93
|
+
return 0;
|
|
94
|
+
})()`;
|
|
95
|
+
let seq = 0;
|
|
96
|
+
export async function reflexTool(sess, a) {
|
|
97
|
+
await arm(sess);
|
|
98
|
+
await run(sess, RUNTIME);
|
|
99
|
+
if (a.action === "list")
|
|
100
|
+
return run(sess, "__wt_rf.snap()");
|
|
101
|
+
if (a.action === "clear") {
|
|
102
|
+
await run(sess, "(() => { for (const k in __wt_rf.rules) delete __wt_rf.rules[k]; })()");
|
|
103
|
+
return { cleared: true };
|
|
104
|
+
}
|
|
105
|
+
if (a.action === "remove") {
|
|
106
|
+
if (!a.id)
|
|
107
|
+
throw new Error("id required");
|
|
108
|
+
const had = await run(sess, `(() => { const had = ${JSON.stringify(a.id)} in __wt_rf.rules; delete __wt_rf.rules[${JSON.stringify(a.id)}]; return had; })()`);
|
|
109
|
+
return { removed: had };
|
|
110
|
+
}
|
|
111
|
+
if (!a.when || !a.do)
|
|
112
|
+
throw new Error("add needs both when and do");
|
|
113
|
+
const w = a.when, d = a.do;
|
|
114
|
+
const cv = w.canvas;
|
|
115
|
+
if (!!w.selector === !!cv)
|
|
116
|
+
throw new Error("when needs exactly one of selector or canvas");
|
|
117
|
+
if (d.type === "click" && !w.selector)
|
|
118
|
+
throw new Error("do.click only works with a selector trigger, use click_xy");
|
|
119
|
+
if (d.type === "click_xy" && (d.x == null || d.y == null))
|
|
120
|
+
throw new Error("click_xy needs x and y");
|
|
121
|
+
if (d.type === "key" && !d.key)
|
|
122
|
+
throw new Error("key needs a key");
|
|
123
|
+
if (d.type === "js" && !d.code)
|
|
124
|
+
throw new Error("js needs code");
|
|
125
|
+
const id = a.id || `r${++seq}`;
|
|
126
|
+
const rule = {
|
|
127
|
+
id,
|
|
128
|
+
sel: w.selector || null,
|
|
129
|
+
text: w.text ? w.text.toLowerCase() : null,
|
|
130
|
+
onlyNew: Boolean(w.only_new),
|
|
131
|
+
enabled: w.enabled !== false,
|
|
132
|
+
names: cv?.names ?? null,
|
|
133
|
+
act: { type: d.type, x: d.x, y: d.y, key: d.key, hold: d.hold_ms ?? 40 },
|
|
134
|
+
cooldown: a.cooldown_ms ?? 100,
|
|
135
|
+
max: a.max_hits ?? 50,
|
|
136
|
+
ttl: Math.min(a.ttl_ms ?? 30000, 600000),
|
|
137
|
+
stop: a.stop_selector || null,
|
|
138
|
+
};
|
|
139
|
+
if (cv)
|
|
140
|
+
await canvasTool(sess, { action: "attach" });
|
|
141
|
+
// fns go in as source so page CSP (no unsafe-eval) can't block them
|
|
142
|
+
const whenFn = cv ? `(E, dist, gap) => (\n${cv.js}\n)` : "null";
|
|
143
|
+
const jsFn = d.type === "js" ? `() => {\n${d.code}\n}` : "null";
|
|
144
|
+
await run(sess, `__wt_rf.add(${JSON.stringify(rule)}, ${whenFn}, ${jsFn})`);
|
|
145
|
+
return { id, armed: true };
|
|
146
|
+
}
|
|
147
|
+
export function statusTool(sess, waitMs = 0) {
|
|
148
|
+
const ms = Math.min(Math.max(waitMs, 0), 30000);
|
|
149
|
+
return run(sess, `new Promise((res) => {
|
|
150
|
+
const t0 = performance.now();
|
|
151
|
+
const snap = () => ({
|
|
152
|
+
probe: Boolean(window.__wt_rt),
|
|
153
|
+
canvas: window.__wt_cv ? { attached: true, frames_seen: window.__wt_cv.n } : { attached: false },
|
|
154
|
+
reflexes: window.__wt_rf ? window.__wt_rf.snap() : []
|
|
155
|
+
});
|
|
156
|
+
const tick = () => {
|
|
157
|
+
const s = snap();
|
|
158
|
+
if (!${ms} || !s.reflexes.some((r) => !r.done) || performance.now() - t0 > ${ms}) res(s);
|
|
159
|
+
else setTimeout(tick, 50);
|
|
160
|
+
};
|
|
161
|
+
tick();
|
|
162
|
+
})`, true);
|
|
163
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export declare function readFeed(sess: any, winMs: number): Promise<{
|
|
2
|
+
active_tab: boolean;
|
|
3
|
+
url?: undefined;
|
|
4
|
+
probe?: undefined;
|
|
5
|
+
last_click?: undefined;
|
|
6
|
+
last_mut_ms_ago?: undefined;
|
|
7
|
+
recent_elements?: undefined;
|
|
8
|
+
reflexes?: undefined;
|
|
9
|
+
canvas?: undefined;
|
|
10
|
+
} | {
|
|
11
|
+
url: any;
|
|
12
|
+
probe: any;
|
|
13
|
+
last_click: any;
|
|
14
|
+
last_mut_ms_ago: any;
|
|
15
|
+
recent_elements: any;
|
|
16
|
+
reflexes: any;
|
|
17
|
+
canvas: any;
|
|
18
|
+
active_tab?: undefined;
|
|
19
|
+
}>;
|