@desklink/axi 0.5.0 → 0.5.2
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/README.md +4 -2
- package/dist/bridge.js +52 -13
- package/dist/cursor-events.js +1 -1
- package/dist/token.js +5 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -45,9 +45,9 @@ desklink-axi look @r1
|
|
|
45
45
|
desklink-axi stop
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
-
`start --cursor hidden` opts into cursor-free capture; omit it for existing behavior. Use `desklink-axi cursor` to drain source-coordinate cursor samples (`x`, `y`, `visible`, `timestamp_us`) from the live session; `dropped:N` reports older samples overwritten after its 10,000-sample buffer fills.
|
|
48
|
+
`start --cursor hidden` opts into cursor-free capture; omit it for existing behavior. Use `desklink-axi cursor` to drain source-coordinate cursor samples (`x`, `y`, `visible`, `timestamp_us`, plus `hotspot` where the compositor reports it) from the live session; `dropped:N` reports older samples overwritten after its 10,000-sample buffer fills. On Wayland a hidden session still runs when positions are unavailable and says why in `cursor.limitation`; see the [cursor protocol](../desktop-host/docs/PROTOCOL.md#cursor-free-capture).
|
|
49
49
|
|
|
50
|
-
`health` reports capture state, latest frame sequence, and a stream-stop reason without OCR. `start` grants all available access to its same-user paired consumer; Linux uses the portal unless `--source x11 --display :N` is given, and macOS uses the main display unless `--source display --display <id>` is given. The portal can require a desktop consent prompt; its single-use restore token is kept in `$XDG_STATE_HOME/desklink-axi
|
|
50
|
+
`health` reports capture state, latest frame sequence, and a stream-stop reason without OCR. `start` grants all available access to its same-user paired consumer; Linux uses the portal unless `--source x11 --display :N` is given, and macOS uses the main display unless `--source display --display <id>` is given. The portal can require a desktop consent prompt; its single-use restore token is kept in `$XDG_STATE_HOME/desklink-axi/<session>.portal-token` (otherwise `~/.local/state/desklink-axi/<session>.portal-token`), atomically replaced with mode 0600 after each grant; `<session>` is `DESKLINK_AXI_SESSION`, the same name the session's socket and browser state use, so two lanes never hand each other's grant to their own consumer. Set `DESKLINK_AXI_RESTORE_TOKEN_FILE` to use a different path. macOS Screen Recording and Accessibility belong to the app that launches the bridge and its engine child. Do not run it unattended against someone else's display. Input and clipboard need no app grants; unavailable OS backends fail per action. Legacy `start --control` is accepted and ignored. See the [clipboard protocol](../desktop-host/docs/PROTOCOL.md#clipboard-over-the-local-protocol) for X11 targets, transfer limits and selection lifetime. `stop` releases held input. Actions acknowledge `input: applied` immediately, without waiting for a frame. Every action (`click`, `type`, `press`, `scroll`, `drag`) accepts `--wait settle|change|none|<milliseconds>`; frame timeout after applied input returns `input: applied; frame: timed out` with success status. `batch` takes a JSON array of 1–30 `[verb, ...args]` steps (click/type/press/scroll/wait/snapshot/assert/tree/marks), runs them in order in one socket round trip, stops on the first failed step and returns a compact per-step result. OCR refs survive advancing frame numbers only while their text and position still match a fresh crop; moved or ambiguous text requires `screen --query`. `screen --query` reuses a prior matching text location for a targeted crop before falling back to full OCR, and reports estimated text tokens. Start with `screen --query` or `--region` before `--full`. For web pages prefer DOM/browser automation when available; AXI is for native apps or whole-desktop tasks. `wait change` and `wait settle` block on engine frame signals rather than polling. `diff` reports repeatedly changing regions separately as `animating`; `diff --include-animating` includes them among ordinary changes. `look` writes a PNG crop. `--help` lists the commands and their flags. Errors are structured and the CLI does not prompt.
|
|
51
51
|
To guide the person without input, use `desklink-axi point 400,300 --label "Here" --timeout 3000`, or `desklink-axi point --clear`. See the [host protocol reference](../desktop-host/docs/PROTOCOL.md#point-on-the-host-desktop) for supported sources, coordinates, and limits.
|
|
52
52
|
|
|
53
53
|
## Browser lane (opt-in, loopback-only)
|
|
@@ -105,6 +105,8 @@ For a remote signed Mac bridge, start it once **inside the permission-granted ap
|
|
|
105
105
|
|
|
106
106
|
For an isolated integration proof run `DESKLINK_AXI_ENGINE=/path/to/engine CARGO_TARGET_DIR=/tmp/task-owned-target node packages/axi/test/flow.mjs` from the repository root. It launches and kills its own Xvfb process by exact PID. `test/a11y-flow.mjs` is the accessibility + marks proof: it also starts a private D-Bus session bus, a private AT-SPI bus, and a GTK3 fixture app (all task-owned, private `XDG_RUNTIME_DIR`), asserts semantic-ref driving of form controls and a file dialog, and prints per-command p50/p95 with `--measure 20`. It skips itself when `busctl`/`dbus-daemon`/`at-spi2-core`/GTK are absent and never touches the user's accessibility bus.
|
|
107
107
|
|
|
108
|
+
For the 200-word OCR and query proof, add `DESKLINK_OCR_SCALE=1` or `DESKLINK_OCR_SCALE=1.5` and `DESKLINK_OCR_EVIDENCE=/absolute/output/directory` to that command. The latter uses a private 3840×2160 display at 1.5 scale. Each run saves the ground truth, OCR, word diff, screenshot and every visible-label query. Add `DESKLINK_OCR_UPDATE_README=1` to the 1x run to replace the README transcript directly with CLI output; no transcript lines are edited by hand. Use `DESKLINK_OCR_SHOWCASE_ONLY=1` with the 1x command to replay the edit and regenerate the transcript without repeating the label-query checks. This proof needs Chromium and Tesseract. Known limitation: a text insertion caret visible in the captured pixels may be read as part of nearby item text (for example a trailing `|`), because frames carry pixels without caret identity. Full-frame OCR upscales 3x in software before Tesseract, so a 3840x2160 frame transiently needs a 11520x6480 (~74.6M px, ~298MB RGBA) buffer plus PNG encode plus Tesseract on the enlarged image.
|
|
109
|
+
|
|
108
110
|
To check the CLI against the AXI spec, run `axi-axi validate "node bin/desklink-axi.js" --dir .` from `packages/axi`.
|
|
109
111
|
|
|
110
112
|
Measured on a private 1280×720 Xvfb client: `diff → click 100,100 → look @r1 → type abc` returned 79 + 79 + 39 + 30 = **227 text tokens** (`@anthropic-ai/tokenizer` 0.0.4), plus ~5 tokens for the 64×64 crop. Four full screenshots would cost ~4×1,229 = **4,916 image tokens** under the same published width×height/750 estimate (~21× difference). This is a short synthetic task, not a production-workload benchmark; the tokenizer is legacy, so absolute counts are approximate.
|
package/dist/bridge.js
CHANGED
|
@@ -249,21 +249,57 @@ export async function serve(args) {
|
|
|
249
249
|
};
|
|
250
250
|
const ocr = async (frame, box = [0, 0, frame.width, frame.height]) => {
|
|
251
251
|
const imagePath = join(dir, 'ocr.png');
|
|
252
|
-
|
|
253
|
-
|
|
252
|
+
// UI glyphs at native size are often below Tesseract's useful x-height.
|
|
253
|
+
// Enlarge the captured pixels, rather than correcting recognized words.
|
|
254
|
+
const scale = 3;
|
|
255
|
+
const input = await cropBuffer(frame, box);
|
|
256
|
+
const { PNG } = await import('pngjs');
|
|
257
|
+
const enlarged = new PNG({ width: input.width * scale, height: input.height * scale });
|
|
258
|
+
for (let y = 0; y < enlarged.height; y++) {
|
|
259
|
+
const sy = Math.max(0, Math.min(input.height - 1, (y + 0.5) / scale - 0.5));
|
|
260
|
+
const top = Math.floor(sy), bottom = Math.min(input.height - 1, top + 1), fy = sy - top;
|
|
261
|
+
for (let x = 0; x < enlarged.width; x++) {
|
|
262
|
+
const sx = Math.max(0, Math.min(input.width - 1, (x + 0.5) / scale - 0.5));
|
|
263
|
+
const left = Math.floor(sx), right = Math.min(input.width - 1, left + 1), fx = sx - left;
|
|
264
|
+
const to = (y * enlarged.width + x) * 4;
|
|
265
|
+
for (let c = 0; c < 3; c++) {
|
|
266
|
+
const a = input.data[(top * input.width + left) * 4 + c];
|
|
267
|
+
const b = input.data[(top * input.width + right) * 4 + c];
|
|
268
|
+
const d = input.data[(bottom * input.width + left) * 4 + c];
|
|
269
|
+
const e = input.data[(bottom * input.width + right) * 4 + c];
|
|
270
|
+
enlarged.data[to + c] = Math.round((a * (1 - fx) + b * fx) * (1 - fy) + (d * (1 - fx) + e * fx) * fy);
|
|
271
|
+
}
|
|
272
|
+
enlarged.data[to + 3] = 255;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
writeFileSync(imagePath, PNG.sync.write(enlarged));
|
|
254
276
|
const homebrew = ['/opt/homebrew/bin/tesseract', '/usr/local/bin/tesseract'].find(existsSync);
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
277
|
+
// Keep receiving capture and input events while recognition is running.
|
|
278
|
+
const output = await new Promise((resolve, reject) => {
|
|
279
|
+
const child = spawn(process.platform === 'darwin' && homebrew ? homebrew : 'tesseract', [imagePath, 'stdout', 'tsv'], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
280
|
+
let result = '';
|
|
281
|
+
child.stdout.setEncoding('utf8');
|
|
282
|
+
child.stdout.on('data', chunk => { result += chunk; });
|
|
283
|
+
const unavailable = () => reject(new Error('ocr-unavailable: install tesseract (brew install tesseract, sudo pacman -S tesseract or sudo apt install tesseract-ocr)'));
|
|
284
|
+
child.once('error', unavailable);
|
|
285
|
+
child.once('close', code => { if (code === 0)
|
|
286
|
+
resolve(result);
|
|
287
|
+
else
|
|
288
|
+
unavailable(); });
|
|
289
|
+
});
|
|
258
290
|
const lines = new Map();
|
|
259
|
-
|
|
291
|
+
const rows = output.split('\n').slice(1).map(line => line.split('\t'));
|
|
292
|
+
for (const cols of rows) {
|
|
260
293
|
if (cols.length < 12 || Number(cols[10]) < 0 || !cols[11]?.trim())
|
|
261
294
|
continue;
|
|
295
|
+
const token = cols[11].trim();
|
|
296
|
+
if (!token)
|
|
297
|
+
continue;
|
|
262
298
|
const key = cols.slice(1, 5).join(':');
|
|
263
299
|
if (!lines.has(key))
|
|
264
300
|
lines.set(key, { ref: `@${frame.seq}.${lines.size + 1}`, text: '', x: Number(cols[6]), y: Number(cols[7]), conf: Number(cols[10]), line: key, words: [] });
|
|
265
301
|
const item = lines.get(key);
|
|
266
|
-
const word = { text:
|
|
302
|
+
const word = { text: token, x: box[0] + Math.floor(Number(cols[6]) / scale), y: box[1] + Math.floor(Number(cols[7]) / scale), w: Math.ceil(Number(cols[8]) / scale), h: Math.ceil(Number(cols[9]) / scale) };
|
|
267
303
|
if (!item.text) {
|
|
268
304
|
item.x = word.x;
|
|
269
305
|
item.y = word.y;
|
|
@@ -464,14 +500,14 @@ export async function serve(args) {
|
|
|
464
500
|
const current = await capture(baseline);
|
|
465
501
|
observed = undefined; // Validation must not consume the diff baseline.
|
|
466
502
|
if (current.seq !== baseline) {
|
|
503
|
+
// Cropping through a widget border or a caret changes segmentation.
|
|
504
|
+
// Validate against the same full-screen context that produced the ref.
|
|
505
|
+
const currentText = await ocr(current);
|
|
467
506
|
for (const ref of args.filter(arg => /^@\d+\.\d+$/.test(arg))) {
|
|
468
507
|
const item = text.find(t => t.ref === ref);
|
|
469
508
|
if (!item)
|
|
470
509
|
throw new Error(`stale-ref: ${ref}; run screen --query`);
|
|
471
|
-
|
|
472
|
-
const right = Math.min(current.width, item.words.at(-1).x + item.words.at(-1).w + 32);
|
|
473
|
-
const bottom = Math.min(current.height, Math.max(...item.words.map(w => w.y + w.h)) + 32);
|
|
474
|
-
if (!(await ocr(current, [x, y, right - x, bottom - y])).some(t => t.text === item.text && Math.abs(t.x - item.x) < 12 && Math.abs(t.y - item.y) < 12))
|
|
510
|
+
if (!currentText.some(t => t.text === item.text && Math.abs(t.x - item.x) < 12 && Math.abs(t.y - item.y) < 12))
|
|
475
511
|
throw new Error(`stale-ref: ${ref} moved or changed; run screen --query`);
|
|
476
512
|
}
|
|
477
513
|
}
|
|
@@ -760,12 +796,15 @@ export async function serve(args) {
|
|
|
760
796
|
const prev = text;
|
|
761
797
|
let kept = [...prev];
|
|
762
798
|
const dirty = [];
|
|
799
|
+
// Recognize complete lines in full context before selecting damage. A
|
|
800
|
+
// cropped caret or widget border must not turn unchanged words into fragments.
|
|
801
|
+
const currentText = await ocr(frame);
|
|
763
802
|
for (const region of frame.damage) {
|
|
764
803
|
const [rx, ry, rw, rh] = region.split(',').map(Number);
|
|
765
804
|
const x = Math.max(0, rx - 32), y = Math.max(0, ry - 32);
|
|
766
805
|
const right = Math.min(frame.width, rx + rw + 32), bottom = Math.min(frame.height, ry + rh + 32);
|
|
767
806
|
kept = kept.filter(t => !t.words.some(w => w.x < right && w.x + w.w > x && w.y < bottom && w.y + w.h > y));
|
|
768
|
-
dirty.push(...
|
|
807
|
+
dirty.push(...currentText.filter(t => t.words.some(w => w.x < right && w.x + w.w > x && w.y < bottom && w.y + w.h > y)));
|
|
769
808
|
}
|
|
770
809
|
text = [...kept, ...dirty.filter((t, i) => !dirty.slice(0, i).some(p => p.text === t.text && Math.abs(p.x - t.x) < 12 && Math.abs(p.y - t.y) < 12))]
|
|
771
810
|
.map((t, i) => ({ ...t, ref: `@${frame.seq}.${i + 1}` }));
|
|
@@ -795,7 +834,7 @@ export async function serve(args) {
|
|
|
795
834
|
}
|
|
796
835
|
if (observed)
|
|
797
836
|
observed.damage = visible;
|
|
798
|
-
const changed = visible.length ? `${visible.length} region since frame ${frame.previous}` : `none since frame ${frame.previous}`;
|
|
837
|
+
const changed = visible.length ? `${visible.length} region${visible.length === 1 ? '' : 's'} since frame ${frame.previous}` : `none since frame ${frame.previous}`;
|
|
799
838
|
return `changed: ${changed}\nregions[${visible.length}]{ref,box}:\n${visible.map((d, i) => ` @r${i + 1},"${d}"`).join('\n')}\nappeared[${Math.min(appeared.length, 20)} of ${appeared.length}]{${fields.join(',')}}:\n${appeared.slice(0, 20).map(t => ` ${fields.map(field => JSON.stringify(field === 'w' ? t.words.at(-1).x + t.words.at(-1).w - t.x : field === 'h' ? Math.max(...t.words.map(w => w.y + w.h)) - t.y : t[field])).join(',')}`).join('\n')}\ngone: ${gone} text items${animating.length ? `\nanimating[${animating.length}]{box}:\n${animating.map(box => ` "${box}"`).join('\n')}\nanimating: ${animating.join('; ')}` : ''}\nhelp[2]:\n desklink-axi look @r1\n desklink-axi screen --query "<words>"`;
|
|
800
839
|
}
|
|
801
840
|
return `changed: none since frame ${frame.seq} (still ${frame.still_ms}ms)\nhelp[1]:\n desklink-axi screen --query "<words>"`;
|
package/dist/cursor-events.js
CHANGED
|
@@ -32,6 +32,6 @@ export class CursorEventBuffer {
|
|
|
32
32
|
}
|
|
33
33
|
export function formatCursorSamples(samples, dropped) {
|
|
34
34
|
return samples.length
|
|
35
|
-
? `cursor[${samples.length}] dropped:${dropped} {sessionId,x,y,visible,timestamp_us}:\n${samples.map(sample => ` ${JSON.stringify(sample)}`).join('\n')}`
|
|
35
|
+
? `cursor[${samples.length}] dropped:${dropped} {sessionId,x,y,visible,timestamp_us,hotspot?}:\n${samples.map(sample => ` ${JSON.stringify(sample)}`).join('\n')}`
|
|
36
36
|
: `cursor: no pending samples; dropped:${dropped}`;
|
|
37
37
|
}
|
package/dist/token.js
CHANGED
|
@@ -2,8 +2,12 @@ import { closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, write
|
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
|
+
// Scoped by the session, exactly like the socket and the browser state beside
|
|
6
|
+
// it: one shared name per user let a second lane's grant overwrite the first
|
|
7
|
+
// lane's single-use token, and each lane's consumer then read the other's.
|
|
5
8
|
export function tokenPath() {
|
|
6
|
-
return process.env.DESKLINK_AXI_RESTORE_TOKEN_FILE
|
|
9
|
+
return process.env.DESKLINK_AXI_RESTORE_TOKEN_FILE
|
|
10
|
+
?? join(process.env.XDG_STATE_HOME ?? join(homedir(), '.local', 'state'), 'desklink-axi', `${process.env.DESKLINK_AXI_SESSION ?? 'default'}.portal-token`);
|
|
7
11
|
}
|
|
8
12
|
// A portal token is single-use. Remove it from the reusable name before open;
|
|
9
13
|
// a failed open must prompt again rather than replay a possibly consumed grant.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@desklink/axi",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"description": "Agent-facing desktop control CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"typescript": "5.9.3"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@desklink/host": "0.5.
|
|
32
|
+
"@desklink/host": "0.5.2",
|
|
33
33
|
"node-datachannel": "0.33.4",
|
|
34
34
|
"playwright-core": "1.62.1",
|
|
35
35
|
"pngjs": "7.0.0"
|