@rebind.gg/mcp-server 0.1.5 → 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.
@@ -0,0 +1,15 @@
1
+ import { expect, test } from "bun:test";
2
+ import { normalizeCombo } from "./keys";
3
+
4
+ test("normalizes every Command spelling models emit", () => {
5
+ for (const alias of ["LCmd", "Cmd", "Command", "⌘", "Super", "LWin", "Win"]) {
6
+ expect(normalizeCombo(`${alias}+L`)).toBe("LMeta+L");
7
+ }
8
+ expect(normalizeCombo("RCmd+Tab")).toBe("RMeta+Tab");
9
+ });
10
+
11
+ test("passes through already-valid combos and bare keys", () => {
12
+ expect(normalizeCombo("LCtrl+Shift+P")).toBe("LCtrl+Shift+P");
13
+ expect(normalizeCombo("Enter")).toBe("Enter");
14
+ expect(normalizeCombo(" LMeta + A ")).toBe("LMeta+A");
15
+ });
package/src/keys.ts ADDED
@@ -0,0 +1,31 @@
1
+ // Shared key-combo normalization for every surface that sends model-authored
2
+ // chords to the relay (the MCP server's `key` tool and the bench executor).
3
+ //
4
+ // Models emit "LCmd"/"Cmd"/"Command"/"⌘" for the macOS Command key, but the SDK
5
+ // key parser only knows LMeta/Meta/Cmd (not the L-prefixed "LCmd"). Without
6
+ // normalization the modifier is silently dropped and the bare letter types.
7
+
8
+ const META = new Set([
9
+ "LCMD",
10
+ "CMD",
11
+ "COMMAND",
12
+ "⌘",
13
+ "LMETA",
14
+ "META",
15
+ "SUPER",
16
+ "LWIN",
17
+ "WIN",
18
+ ]);
19
+ const RMETA = new Set(["RCMD", "RCOMMAND", "RMETA", "RWIN"]);
20
+
21
+ export function normalizeCombo(combo: string): string {
22
+ return combo
23
+ .split("+")
24
+ .map((t) => {
25
+ const u = t.trim().toUpperCase();
26
+ if (META.has(u)) return "LMeta";
27
+ if (RMETA.has(u)) return "RMeta";
28
+ return t.trim();
29
+ })
30
+ .join("+");
31
+ }
package/src/llm.ts ADDED
@@ -0,0 +1,157 @@
1
+ // Minimal OpenRouter tool-calling step. LEAF module: node fs/fetch only, ZERO
2
+ // imports from the MCP SDK / image pipeline / session — the bench re-exports
3
+ // llmStep from here and must never transitively pull those in (see the covenant
4
+ // in ./prompts.ts). Both the bench loop and the server's delegate_task /
5
+ // rebind_versus tools call it.
6
+ //
7
+ // Deliberately NOT @rebind/llm: that transport force-adds a web_search tool and
8
+ // drops usage. Here we want a clean, controlled step — tools in, the raw
9
+ // assistant message + parsed tool calls + exact usage out. Temperature 0.
10
+
11
+ const URL =
12
+ process.env.OPENROUTER_URL || "https://openrouter.ai/api/v1/chat/completions";
13
+ // hard ceiling on one model round-trip. fetch has no built-in timeout, so a
14
+ // wedged connection would hang forever; abort so the caller fails and moves on.
15
+ const LLM_TIMEOUT_MS = Number(process.env.BENCH_LLM_TIMEOUT || 60000);
16
+ const MAX_RETRIES = Number(process.env.OPENROUTER_MAX_RETRIES || 3);
17
+
18
+ // key resolution: env var first, then the repo root's decrypted .env.production
19
+ // so `just bench-demo` (and a bare server) work from a fresh shell without
20
+ // exporting anything. Resolved LAZILY — importing this module must have no fs
21
+ // side effect (the production server imports it just to register two tools).
22
+ let KEY: string | undefined;
23
+ function key(): string {
24
+ if (KEY !== undefined) return KEY;
25
+ KEY = process.env.OPENROUTER_API_KEY || keyFromRootEnv();
26
+ return KEY;
27
+ }
28
+ function keyFromRootEnv(): string {
29
+ try {
30
+ const txt = require("node:fs").readFileSync(
31
+ new globalThis.URL("../../../.env.production", import.meta.url),
32
+ "utf8",
33
+ ) as string;
34
+ return /^OPENROUTER_API_KEY=(.*)$/m.exec(txt)?.[1]?.trim() ?? "";
35
+ } catch {
36
+ return "";
37
+ }
38
+ }
39
+
40
+ export interface Tool {
41
+ type: "function";
42
+ function: {
43
+ name: string;
44
+ description: string;
45
+ parameters: Record<string, unknown>;
46
+ };
47
+ }
48
+
49
+ // OpenAI/OpenRouter message shape. `content` is a string or multimodal parts.
50
+ export type Msg = Record<string, unknown>;
51
+
52
+ export interface ParsedCall {
53
+ id: string;
54
+ name: string;
55
+ args: Record<string, unknown>;
56
+ }
57
+
58
+ export interface Step {
59
+ /** raw assistant message, pushed back verbatim so tool_call_ids line up */
60
+ message: Msg;
61
+ toolCalls: ParsedCall[];
62
+ /** OpenRouter-reported usage for THIS request; cost is exact USD credits. */
63
+ usage?: { prompt: number; completion: number; cost: number };
64
+ }
65
+
66
+ const safeParse = (s: string): Record<string, unknown> => {
67
+ try {
68
+ return JSON.parse(s || "{}");
69
+ } catch {
70
+ return {};
71
+ }
72
+ };
73
+
74
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
75
+
76
+ export async function llmStep(
77
+ messages: Msg[],
78
+ tools: Tool[],
79
+ model: string,
80
+ ): Promise<Step> {
81
+ if (!key()) throw new Error("set OPENROUTER_API_KEY (Bun auto-loads .env)");
82
+
83
+ // usage.include makes OpenRouter return exact token counts + USD cost per
84
+ // request — no client-side pricing table to drift. Omit `tools` entirely when
85
+ // empty: strict providers 400 on an empty tools array.
86
+ const body = JSON.stringify({
87
+ model,
88
+ messages,
89
+ ...(tools.length ? { tools } : {}),
90
+ max_tokens: 4096,
91
+ temperature: 0,
92
+ usage: { include: true },
93
+ });
94
+
95
+ let res: Response;
96
+ for (let attempt = 0; ; attempt++) {
97
+ try {
98
+ res = await fetch(URL, {
99
+ method: "POST",
100
+ headers: {
101
+ Authorization: `Bearer ${key()}`,
102
+ "Content-Type": "application/json",
103
+ "HTTP-Referer": "https://rebind.gg",
104
+ "X-Title": "Rebind Agent Bench",
105
+ },
106
+ body,
107
+ signal: AbortSignal.timeout(LLM_TIMEOUT_MS),
108
+ });
109
+ } catch (e) {
110
+ if (e instanceof Error && e.name === "TimeoutError") {
111
+ throw new Error(
112
+ `openrouter request timed out after ${LLM_TIMEOUT_MS}ms`,
113
+ );
114
+ }
115
+ throw e;
116
+ }
117
+ if (res.ok) break;
118
+ // 429 (rate limit) and 5xx are transient — parallel fan-out reliably trips
119
+ // 429 on the flash tiers. Back off and retry; surface anything else at once.
120
+ if ((res.status === 429 || res.status >= 500) && attempt < MAX_RETRIES) {
121
+ await sleep(400 * 2 ** attempt);
122
+ continue;
123
+ }
124
+ throw new Error(`openrouter ${res.status}: ${await res.text()}`);
125
+ }
126
+
127
+ const j = (await res.json()) as {
128
+ choices?: {
129
+ message?: {
130
+ content?: string | null;
131
+ tool_calls?: {
132
+ id: string;
133
+ function: { name: string; arguments: string };
134
+ }[];
135
+ };
136
+ }[];
137
+ usage?: {
138
+ prompt_tokens?: number;
139
+ completion_tokens?: number;
140
+ cost?: number;
141
+ };
142
+ };
143
+ const m = j.choices?.[0]?.message ?? { content: null };
144
+ const toolCalls: ParsedCall[] = (m.tool_calls ?? []).map((t) => ({
145
+ id: t.id,
146
+ name: t.function.name,
147
+ args: safeParse(t.function.arguments),
148
+ }));
149
+ const usage = j.usage
150
+ ? {
151
+ prompt: j.usage.prompt_tokens ?? 0,
152
+ completion: j.usage.completion_tokens ?? 0,
153
+ cost: j.usage.cost ?? 0,
154
+ }
155
+ : undefined;
156
+ return { message: m as Msg, toolCalls, usage };
157
+ }
package/src/prompts.ts ADDED
@@ -0,0 +1,101 @@
1
+ // Canonical agent-facing copy, shared by the Rebind MCP server (this package)
2
+ // and the agent bench (packages/mcp-server/bench). ONE source of truth for the tool
3
+ // descriptions, efficiency doctrine, and machine-probe commands that both
4
+ // surfaces show the model, so they can never drift.
5
+ //
6
+ // PURE strings + string builders — ZERO runtime imports. That is deliberate:
7
+ // the colocated bench (../bench) imports this by relative path and must NOT pull
8
+ // in the MCP SDK, image pipeline, or calibration that live elsewhere in src/.
9
+ //
10
+ // What is NOT here, by design: the screenshot/click descriptions. The server's
11
+ // click is verified + calibrated; the bench's is a primitive hidMoveTo+press.
12
+ // A shared description would make one of them lie, so each keeps its own.
13
+
14
+ export type OS = "macOS" | "Linux" | "Windows";
15
+
16
+ // --- machine probes (run relay-side through System.Exec) ---
17
+
18
+ /** CLIs worth surfacing to the model; prints the ones present, space-separated. */
19
+ export const PROBE_CLIS_CMD = `for c in python3 osascript jq curl sed awk sort uniq grep wc open unzip tar; do command -v $c >/dev/null 2>&1 && printf "%s " $c; done`;
20
+
21
+ /** List up to 40 installed GUI apps, comma-separated, for the given OS. */
22
+ export function probeAppsCmd(os: OS): string {
23
+ return os === "macOS"
24
+ ? `ls /Applications /System/Applications 2>/dev/null | grep '\\.app$' | sed 's/\\.app$//' | sort -u | head -40 | tr '\\n' ','`
25
+ : `ls /usr/share/applications 2>/dev/null | sed 's/\\.desktop$//' | sort -u | head -40 | tr '\\n' ','`;
26
+ }
27
+
28
+ /** How to launch a GUI app by name via run_lua, per OS. */
29
+ export function openAppHow(os: OS): string {
30
+ if (os === "macOS") return 'System.ExecDetached("open", {"-a", "<Name>"})';
31
+ if (os === "Linux") return 'System.ExecDetached("<name>", {})';
32
+ return 'System.ExecDetached("cmd", {"/c","start","","<name>"})';
33
+ }
34
+
35
+ /** The shortcut modifier per OS — the #1 fix for wrong-key shortcut failures.
36
+ * Returned as a fragment the caller wraps ("...shortcuts use <this>."). */
37
+ export function modifierNote(os: OS): string {
38
+ return os === "macOS"
39
+ ? "LMeta (Command): LMeta+A select all, LMeta+C copy, LMeta+V paste, LMeta+L focus the browser address bar"
40
+ : "LCtrl: LCtrl+A select all, LCtrl+C copy, LCtrl+V paste, LCtrl+L focus the address bar";
41
+ }
42
+
43
+ // --- efficiency doctrine ---
44
+
45
+ /** Tool-preference ladder. run_lua only appears when the surface/arm has it. */
46
+ export function toolPriority(hasLua: boolean): string {
47
+ return `Tool priority, most to least preferred: ${hasLua ? "script (run_lua) > " : ""}keyboard > mouse > screenshot. If an application accepts keyboard input for the job — typed digits and operators, shortcuts, menu accelerators, type-to-select — use the keyboard: it is deterministic and needs no coordinates. Use the mouse only when no keyboard path exists, and screenshots only when you genuinely need to see state.`;
48
+ }
49
+
50
+ export const BATCHING_DOCTRINE =
51
+ "When you must drive the GUI: do not verify after every action. Batch predictable action sequences — keystrokes especially — and verify once at the end via values (file contents, clipboard, command output) rather than screenshots.";
52
+
53
+ /** GUI traps that reliably strand an agent, per OS. Returned as one line or "". */
54
+ export function guiTips(os: OS): string {
55
+ if (os === "macOS")
56
+ return (
57
+ "GUI traps to avoid on macOS: (1) In Finder, Return/Enter RENAMES the selected item — it does NOT open it; open it with a DOUBLE-CLICK, or LMeta+O, or LMeta+Down. " +
58
+ '(2) To open a URL, file, or app, prefer run_lua `System.ExecDetached("open", {"<url-or-path>"})` (or `{"-a","<App>"}`) over clicking the app\'s chrome — it is one call and cannot get lost. ' +
59
+ "(3) If an unexpected window or dialog blocks you, handle it before continuing — never loop on it: dismiss a dialog with Escape, cycle between windows of the front app with LMeta+Grave (backtick), and close an unwanted window or tab with LMeta+W. " +
60
+ '(4) To READ the current web page without a screenshot, script the browser: `local app = App.Front().name` (App.Front returns a table — use .name), then run_lua `System.Exec([[osascript -e \'tell application "<Name>" to get title of active tab of front window\']])` for the page title (swap `title` for `URL` to read the address). The tab title IS the page\'s heading for reporting purposes — report it as-is; do NOT try AppleScript `execute javascript` to scrape the DOM, it is disabled by default in every browser and the quoting will eat your remaining steps. For full page CONTENT (lists, articles, multiple pages), fetch the URL directly instead: `System.Exec("curl -s <url>")` and parse the HTML. ' +
61
+ "(5) To DRIVE a web form (fill fields, click submit) you must work by SIGHT — call position_window on the browser FIRST so the whole form is visible and click coordinates are stable, then: screenshot, click a field, type its value, and after the last field click the submit button; screenshot to confirm each step landed. Do NOT use osascript `execute javascript` to fill or submit forms — it is disabled by default in browsers and the nested shell quoting fails."
62
+ );
63
+ return (
64
+ "GUI traps: to open a URL/file/app, prefer run_lua `System.ExecDetached` over clicking the app's UI. Open a file by double-clicking it. " +
65
+ "If an unexpected window/dialog blocks you, dismiss it (Escape) or close it (LCtrl+W) before continuing — never loop on it."
66
+ );
67
+ }
68
+
69
+ export const DETERMINISTIC_WORK_DOCTRINE =
70
+ "run_lua is your PRIMARY tool — reach for it FIRST and use it aggressively. It exposes the ENTIRE machine SDK (every namespace below), so almost anything a task needs — file operations, text processing, arithmetic, clipboard, launching and positioning apps, shell commands, reading the screen — is ONE run_lua call, never a click loop. Do not operate calculators, browsers, or file managers through the GUI when a script can do the work. Fall back to screenshot/click ONLY for state that exists purely on screen and cannot be scripted.";
71
+
72
+ // --- tool descriptions (shared only where the underlying behavior is identical
73
+ // across both surfaces: run_lua, lua_docs, and key all hit the same relay) ---
74
+
75
+ export const KEY_DESC =
76
+ "Press a key or combo. Modifiers: LCtrl, LShift, LAlt, LMeta (Command on macOS — use LMeta for macOS shortcuts, LCtrl on Windows/Linux). " +
77
+ 'Non-modifier keys are NAMES, never glyphs: arrows are "Up"/"Down"/"Left"/"Right" (not ↑↓←→); also "Enter", "Tab", "Escape", "Space", "Backspace", "Delete", "Home", "End", "PageUp", "PageDown", "F1".."F12". ' +
78
+ 'Punctuation is spelled out: "Equal", "Minus", "Comma", "Period", "Slash", "Semicolon", "LeftBracket", "RightBracket", "Grave", "Backslash". ' +
79
+ 'Combos join with "+", e.g. "LCtrl+L" (focus address bar), "LMeta+A" (select all), "LAlt+Tab". ' +
80
+ 'Zoom in is "LMeta+Equal" / "LCtrl+Equal" (never "++" — the extra + splits into empty keys); zoom out uses "Minus". Escape closes dialogs; Space / PageDown scroll.';
81
+
82
+ export const RUN_LUA_DESC =
83
+ "Run a Luau script on the connected machine through Rebind's engine and return its `return` value as JSON. " +
84
+ "This is your PRIMARY tool and FIRST choice — it exposes the ENTIRE machine SDK (every namespace listed below; call lua_docs for the full function list) and is far faster, cheaper, and more reliable than the screenshot-and-click loop, which it sidesteps entirely. Default to it for ANYTHING that can be scripted. " +
85
+ "The chunk runs ONCE and must end with `return <value>`: script hooks (OnDown/OnTick) never fire here, and coroutine-only calls " +
86
+ '(Sleep, HID.Press, Dialog file pickers, Screen.Capture, Screen.FindImage, Screen.SearchForColor over 200k pixels) error — wait with System.Exec("sleep 0.5") instead, and take screenshots with the screenshot tool, not from a chunk. There is no Lua stdlib `io`/`os`/`require`. ' +
87
+ "Use it to launch apps (System.ExecDetached), place windows (Window.Wait / Window.Move — on macOS the window handle is advisory and Move targets the FRONTMOST window, so bring the app forward first), run shell commands " +
88
+ "(System.Exec(cmd) -> {exit,stdout,stderr}, absolute paths fine; inside a chunk it runs asynchronously on a background thread and does not block, so a slow command is safe), find UI by color in a small region (Screen.SearchForColor({x1,y1,x2,y2}, \"rrggbb\", tolerance), region under 200k pixels), type, click, read pixels (Screen.GetPixelColor), manage processes. " +
89
+ "File.* (Read/Write/Append/ReadJSON/WriteJSON/Exists/Delete/List/MkDir/RmDir/Copy/Move/GetSize/GetTime/GetScriptDir) is jailed to the script dir: paths RELATIVE, absolute rejected. " +
90
+ 'A folder described as on the Desktop or in home is reachable at its BARE name from the jail root (e.g. File.List("myfolder")) — never prefix "Desktop/" or "~". ' +
91
+ "JSON is JSON.Parse(s) and JSON.Stringify(v) — there is no JSON.Decode/Encode; for JSON files File.ReadJSON/File.WriteJSON is one call. " +
92
+ "System.Exec commands run in an UNRELATED working directory — a relative shell path does NOT land in the File jail, so do file work with File.* (File.Write always works in the jail), and use absolute paths inside shell commands. " +
93
+ 'System.Exec takes ONE shell-command STRING — System.Exec("ls /tmp") — and its optional second parameter is {timeout, cwd}, NOT an argument array (an args table there is silently ignored and the bare command runs); only System.ExecDetached takes an args array. ' +
94
+ 'There is NO io.* or os.* — file work is File.*, shell work is System.Exec. App has Front/IsFront/IsRunning/Activate/Hide/Quit only — there is no App.List (installed apps are listed in your instructions). HID.Press takes ONE key; fire chords with HID.Combo("LMeta+C"). ' +
95
+ "All namespaces: System, HID, Input, Screen, Window, App, Process, File, Clipboard, Net, Env, Log, UI, Bind, Timer, Math, JSON, Pipe, Audio, Dialog, Script, Regex, Config, Hash, Codec, Macro, Registry. " +
96
+ 'Example (move all .log files into d/logs and report the count): `File.MkDir("d/logs"); local n=0; for _,f in ipairs(File.List("d")) do if f:match("%.log$") then File.Move("d/"..f, "d/logs/"..f); n=n+1 end end; return n`. ' +
97
+ "Call `lua_docs` for exact signatures BEFORE writing nontrivial scripts — do not guess APIs.";
98
+
99
+ export const LUA_DOCS_DESC =
100
+ "Return the full Luau SDK type definitions for run_lua: every namespace, function signature, and type (~5k tokens, one call is enough). " +
101
+ "Read this BEFORE writing any nontrivial run_lua script instead of guessing APIs.";
package/src/rebind.d.luau CHANGED
@@ -183,51 +183,38 @@ export type MacroAction = MacroMoveAction | MacroExplicitAction
183
183
 
184
184
  -- ui widget option types
185
185
 
186
- export type ToggleOpts = {
186
+ export type WidgetOpts = {
187
187
  label: string?,
188
+ tooltip: string?,
189
+ group: string?,
190
+ tab: string?,
191
+ showIf: string?,
188
192
  }
189
193
 
190
- export type SliderOpts = {
191
- label: string?,
194
+ export type ToggleOpts = WidgetOpts
195
+
196
+ export type SliderOpts = WidgetOpts & {
192
197
  min: number?,
193
198
  max: number?,
194
199
  step: number?,
195
200
  suffix: string?,
196
- tooltip: string?,
197
- group: string?,
198
- tab: string?,
199
- showIf: string?,
200
201
  }
201
202
 
202
- export type KeybindOpts = {
203
- label: string?,
204
- }
203
+ export type KeybindOpts = WidgetOpts
205
204
 
206
- export type SelectOpts = {
207
- label: string?,
208
- }
205
+ export type SelectOpts = WidgetOpts
209
206
 
210
- export type TextOpts = {
211
- label: string?,
207
+ export type TextOpts = WidgetOpts & {
212
208
  placeholder: string?,
213
209
  maxLength: number?,
214
- tooltip: string?,
215
- group: string?,
216
- tab: string?,
217
- showIf: string?,
218
210
  }
219
211
 
220
- export type ColorOpts = {
221
- label: string?,
222
- tooltip: string?,
223
- group: string?,
224
- tab: string?,
225
- showIf: string?,
226
- }
212
+ export type ColorOpts = WidgetOpts
227
213
 
228
214
  export type RecordOpts = {
229
215
  ignore_mouse: boolean?,
230
216
  ignore_keyboard: boolean?,
217
+ -- default "normal"
231
218
  precision: string?,
232
219
  }
233
220
 
@@ -251,14 +238,20 @@ export type RebindInfo = {
251
238
  runtime_uuid: string,
252
239
  id: string?,
253
240
  instance: string,
241
+ -- host operating system: "windows" | "macos" | "linux"
242
+ platform: string,
254
243
  }
255
244
 
256
245
  -- global functions
257
246
 
258
247
  declare function Run(fn: () -> ()): TaskHandle
259
248
  -- RunAsync was removed. Use Run() directly.
249
+ -- coroutine-only: Sleep must be called inside Run()/a scheduled coroutine.
250
+ -- Calling it from a plain hook body or a one-shot exec chunk errors at runtime.
260
251
  declare function Sleep(ms: number): ()
261
252
  declare function After(ms: number, fn: () -> ()): TaskHandle
253
+ -- wraps fn so it runs on the coroutine scheduler; when auto-spawned the call
254
+ -- returns false instead of fn's results.
262
255
  declare function Async(fn: (...any) -> ...any): (...any) -> ...any
263
256
  declare function print(...: any): ()
264
257
  declare function require(module: string): any
@@ -270,7 +263,9 @@ declare _REBIND: RebindInfo
270
263
  -- system namespace
271
264
 
272
265
  export type ExecOptions = {
266
+ -- milliseconds; default 5000. The process is killed on expiry and Exec errors.
273
267
  timeout: number?,
268
+ -- working directory for the command; default: the host process's cwd.
274
269
  cwd: string?,
275
270
  }
276
271
 
@@ -289,8 +284,18 @@ declare System: {
289
284
  Mouse: () -> (number, number),
290
285
  Screen: () -> (number, number),
291
286
  Window: () -> WindowInfo,
287
+ -- runs cmd through the shell (sh -c / cmd.exe), output capped at 64KB per
288
+ -- stream. Requires the 'exec' permission. Return shape {exit,stdout,stderr}
289
+ -- unchanged. Inside Run()/a coroutine it runs async off the tick thread
290
+ -- (yields, resumes with the result on a later tick). From a plain hook body
291
+ -- or one-shot chunk it falls back to bounded blocking (deprecated; warns
292
+ -- once, blocks the tick thread — wrap it in Run()).
292
293
  Exec: (cmd: string, options: ExecOptions?) -> ExecResult,
294
+ -- fire-and-forget spawn (no shell, argv array); returns the pid.
293
295
  ExecDetached: (cmd: string, args: { string }?, options: ExecDetachedOptions?) -> number,
296
+ -- types text through the host OS (Unicode aware), never through the HID
297
+ -- transport. raises when the OS refuses the injection.
298
+ TypeText: (text: string) -> (),
294
299
  }
295
300
 
296
301
  -- hid namespace
@@ -298,6 +303,8 @@ declare System: {
298
303
  declare HID: {
299
304
  Down: (code: string) -> (),
300
305
  Up: (code: string) -> (),
306
+ -- coroutine-only (sleeps for holdMs): call inside Run(). From a plain
307
+ -- chunk use Down+Up or Combo instead.
301
308
  Press: (code: string, holdMs: number?) -> (),
302
309
  -- fire a chord in one synchronous call (press L→R, release R→L). no sleep,
303
310
  -- so callable directly from a Bind action / OnDown. modifier tokens accept
@@ -361,6 +368,8 @@ declare Timer: {
361
368
  -- macro namespace
362
369
 
363
370
  declare Macro: {
371
+ -- mode: "parallel" (default, plays alongside running macros) | "replace"
372
+ -- (stops all running macros first).
364
373
  Play: (macro: { MacroAction }, speed: number?, mode: string?) -> MacroHandle,
365
374
  StopAll: () -> (),
366
375
  Record: (options: RecordOpts?) -> (),
@@ -378,6 +387,7 @@ declare Math: {
378
387
  Spline: (macro: { MacroAction }, tension: number) -> { MacroAction },
379
388
  Resample: (macro: { MacroAction }, intervalMs: number) -> { MacroAction },
380
389
  TimeComp: (macro: { MacroAction }, targetMs: number) -> { MacroAction },
390
+ -- mode: "linear" | anything else = positional curve (default).
381
391
  Interpolate: (macro: { MacroAction }, intervalMs: number, mode: string?) -> { MacroAction },
382
392
  }
383
393
 
@@ -426,10 +436,52 @@ export type MonitorEntry = {
426
436
  primary: boolean,
427
437
  }
428
438
 
439
+ export type CaptureOptions = {
440
+ -- 1-based display index; default: the display under the cursor.
441
+ display: number?,
442
+ -- display-local logical coords; default: the whole display.
443
+ region: { x: number, y: number, w: number, h: number }?,
444
+ -- downscale so the long edge is at most this; default and 0: native.
445
+ max_edge: number?,
446
+ }
447
+
448
+ export type CaptureResult = {
449
+ image: string, -- base64 PNG
450
+ width: number,
451
+ height: number,
452
+ display: MonitorEntry,
453
+ cursor: { x: number, y: number },
454
+ }
455
+
456
+ export type FindImageOptions = {
457
+ display: number?,
458
+ region: { x: number, y: number, w: number, h: number }?,
459
+ -- 0.0 exact .. 1.0, per-channel fraction of 255 (same scale as SearchForColor).
460
+ tolerance: number?,
461
+ -- ratios to resample the needle by before scanning, tried in order; default { 1 }.
462
+ scales: { number }?,
463
+ }
464
+
465
+ export type ImageMatch = {
466
+ x: number,
467
+ y: number,
468
+ width: number,
469
+ height: number,
470
+ }
471
+
429
472
  declare Screen: {
430
473
  GetPixelColor: (x: number?, y: number?) -> string,
474
+ -- reads the published frame. regions up to 200k physical pixels answer on the
475
+ -- calling thread; larger ones yield inside Run() and are refused outside it.
431
476
  SearchForColor: (region: {number}, color: string, tolerance: number?) -> PixelMatch?,
432
477
  List: () -> { MonitorEntry },
478
+ -- yields inside Run(); refused outside a coroutine.
479
+ Capture: (options: CaptureOptions?) -> CaptureResult,
480
+ -- needle is a PNG path relative to the script directory. yields inside Run(); refused outside a coroutine.
481
+ FindImage: (needle: string, options: FindImageOptions?) -> ImageMatch?,
482
+ -- usable desktop of the display containing the point (cursor when omitted).
483
+ -- Windows only; raises elsewhere.
484
+ WorkArea: (x: number?, y: number?) -> WindowPos,
433
485
  }
434
486
 
435
487
  -- window namespace
@@ -455,6 +507,10 @@ declare Window: {
455
507
  Kill: (handle: number?) -> (),
456
508
  IsVisible: (handle: number) -> boolean,
457
509
  IsActive: (handle: number?) -> boolean,
510
+ GetForeground: () -> number?,
511
+ FromPoint: (x: number, y: number) -> number?,
512
+ -- "minimized" | "maximized" | "normal". Windows only; raises elsewhere.
513
+ GetState: (handle: number?) -> string,
458
514
  Wait: (title: string, timeout: number?) -> number?,
459
515
  WaitActive: (title: string, timeout: number?) -> boolean,
460
516
  WaitClose: (title: string, timeout: number?) -> boolean,
@@ -510,16 +566,19 @@ declare File: {
510
566
  Append: (path: string, content: string) -> (),
511
567
  Exists: (path: string) -> boolean,
512
568
  Delete: (path: string) -> boolean,
569
+ -- nil path lists the script's own directory. Paths are relative to the
570
+ -- script dir (the File.* jail); absolute paths are rejected.
513
571
  List: (path: string?) -> { string },
514
572
  MkDir: (path: string) -> boolean,
515
- RmDir: (path: string) -> (),
573
+ -- recursive; returns true even when the directory did not exist.
574
+ RmDir: (path: string) -> boolean,
575
+ Copy: (src: string, dst: string) -> (),
576
+ Move: (src: string, dst: string) -> (),
516
577
  ReadJSON: (path: string) -> any,
517
578
  WriteJSON: (path: string, value: any) -> (),
518
579
  GetScriptDir: () -> string,
519
580
  GetSize: (path: string) -> number,
520
581
  GetTime: (path: string) -> number,
521
- Copy: (src: string, dst: string) -> (),
522
- Move: (src: string, dst: string) -> (),
523
582
  IsDir: (path: string) -> boolean,
524
583
  IsFile: (path: string) -> boolean,
525
584
  }
@@ -566,6 +625,8 @@ export type DialogFileOptions = {
566
625
  filters: { DialogFileFilter }?,
567
626
  }
568
627
 
628
+ -- every Dialog call is coroutine-only: it yields until the dialog closes and
629
+ -- errors outside Run(). the file and folder pickers return nil on cancel.
569
630
  declare Dialog: {
570
631
  Message: (text: string, options: DialogMessageOptions?) -> (),
571
632
  Confirm: (text: string, options: DialogMessageOptions?) -> boolean,
@@ -694,5 +755,9 @@ declare Registry: {
694
755
  -- declare function OnMove(dx: number, dy: number): boolean? -- return value only used with mouse_block=true (hardware mode only)
695
756
  -- declare function OnScroll(delta: number): boolean?
696
757
  -- declare function OnScrollH(delta: number): boolean?
697
- -- declare function OnError(): ()
758
+ -- declare function OnInputDown(key: string): ()
759
+ -- declare function OnInputUp(key: string, durationMs: number): ()
760
+ -- declare function OnInputMove(dx: number, dy: number): ()
761
+ -- declare function OnInputScroll(delta: number): ()
762
+ -- declare function OnError(message: string): ()
698
763
  -- declare function OnReload(): ()
package/src/session.ts CHANGED
@@ -77,7 +77,8 @@ export class Session {
77
77
  }
78
78
  if (!inImageBounds(this.lastShot, ix, iy)) {
79
79
  throw new Error(
80
- `(${ix}, ${iy}) is outside the last screenshot (${this.lastShot.imageWidth}x${this.lastShot.imageHeight}) — take a fresh screenshot`,
80
+ `(${ix}, ${iy}) is outside the last screenshot, which is ${this.lastShot.imageWidth}x${this.lastShot.imageHeight} pixels, 0-indexed from the top-left. ` +
81
+ `Emit pixel coordinates within it — if you emitted 0-1000-normalized coordinates, multiply by the image dimensions instead — or take a fresh screenshot.`,
81
82
  );
82
83
  }
83
84
  return mapToScreen(this.lastShot, ix, iy);