qiksy-mcp 1.30.0 → 1.41.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/AGENT-GUIDE.md CHANGED
@@ -44,6 +44,31 @@ instead.
44
44
  Need eval? Say so and let the human decide. Everything else that
45
45
  looks impossible is a verb below, or a rung on [the ladder](#when-a-control-refuses--the-ladder).
46
46
 
47
+ **And three things that used to cost a dozen calls now cost one.**
48
+
49
+ - **A TABLE INSTEAD OF A PAGE — `qa_collect`.** Name which nodes (a CSS `selector`, a `role`, a
50
+ fragment of the name, scoped by `within`) and which properties to `take`: `text`, `value`,
51
+ `name`, `role`, `tag`, `selector`, `href`, `state`, `geometry`, plus `attr:<name>` and
52
+ `style:<property>`. «Every row with its price», «every input with what it holds» is one answer
53
+ instead of a snapshot plus a read per element — and `value` is what a control HOLDS, which a
54
+ snapshot cannot tell you.
55
+ - **SOMETHING HAPPENS LATER — `qa_watch`.** Every other verb answers about NOW, so a toast that
56
+ appears and leaves, a request fired by a timer, a late row or a live-region announcement are
57
+ invisible to a snapshot taken before or after them, and polling misses the gaps between looks.
58
+ Start a watch (`mutations` · `network` · `console` · `announcements`), do the thing, read the
59
+ buffer. **Every watch expires by itself** — there is no «until stopped», because a watch left
60
+ running is silent and the readings taken after it are the ones that quietly go wrong.
61
+ - **ASK A REPLY FOR THE FIELDS YOU WANT.** The big reads take `select` (keep these paths) and
62
+ `omit` (drop these): `qa_snapshot { select: ["nodes[].name", "nodes[].ref"] }`. A path that
63
+ matches nothing is reported with the names that ARE there, so a mistyped field costs one call
64
+ rather than a bigger request.
65
+
66
+ **When the bridge genuinely cannot — `qa_gap`.** One sentence saying what you needed, and it lands
67
+ in a file the human reads. This is the sanctioned way out of a dead end; the unsanctioned one —
68
+ quietly switching to another browser driver — ends the run's meaning, and they find out from the
69
+ results. Use it for a missing capability, never for a control that refused once: that is the
70
+ ladder.
71
+
47
72
  ---
48
73
 
49
74
  ## Setup, once
package/README.md CHANGED
@@ -22,7 +22,7 @@ unless the licence (or the 28-day trial) is valid, so once a trial lapses *every
22
22
  tool fails — `qa_export` included — with a message saying so. Do not plan a
23
23
  free-tier integration around read-only tools.
24
24
 
25
- **npm carries `qiksy-mcp@1.8.0`** — the current build, with all thirty-six tools
25
+ **npm carries `qiksy-mcp@1.41.0`** — the current build, with all sixty-one tools
26
26
  including a filterable full-page `qa_snapshot`, `qa_fill_json` (a whole wizard step —
27
27
  custom dropdowns, calendars, cascaders, trees, tags and time included — in one call),
28
28
  the `qa_pick_*` verbs for the composites, `qa_probe` for the one control that refuses,
@@ -52,7 +52,7 @@ Flags: `--dir <project>`, `--out <path>`, `--no-claude` (print the block instead
52
52
 
53
53
  ## Tools
54
54
 
55
- Thirty-six in total. **Read** — sense the page (all accept an optional `tabId` from `qa_tabs`; default = the tabs you attached to the agent session, or the active tab if you attached none):
55
+ Fifty-five in total. **Read** — sense the page (all accept an optional `tabId` from `qa_tabs`; default = the tabs you attached to the agent session, or the active tab if you attached none):
56
56
 
57
57
  | Tool | What it returns |
58
58
  |---------------|-----------------|
@@ -60,8 +60,26 @@ Thirty-six in total. **Read** — sense the page (all accept an optional `tabId`
60
60
  | `qa_status` | URL + error/warning/form counts + isolated-login name. |
61
61
  | `qa_findings` | Findings (with selectors + detail); `severity` = `all` \| `error` \| `warning`. |
62
62
  | `qa_export` | The full `qa-export/v1` bundle: findings, failed requests + bodies, form structure, repro steps, env. |
63
+ | `qa_annotate` | **Marks that name an ELEMENT, not a coordinate.** A box, a numbered pin, or an arrow between two controls — placed from the page at capture time, so the same instruction draws the correct picture after a re-render or at another width. `fromFindings` makes the page's own findings draw themselves. A mark whose element is not there is reported, never dropped. |
64
+ | `qa_focus_proof` | **Is focus visible on this control?** Two measurements instead of an argument: which ring-drawing property changed, and what fraction of the PIXELS differ between the two frames. A screenshot of a missing ring looks exactly like one of a faint ring; «0% of the pixels differ» does not. |
65
+ | `qa_wiring` | **Which error message is attached to which field, and which to nothing.** Proximity on screen is not association; assistive technology reads the attachment. Under-reports on purpose — a message inside its label, or a summary linking to its fields, is correct by another route. |
66
+ | `qa_diff` | **What changed, by control rather than by pixel.** «The Submit button moved 8px down, padding 8 → 12, and it went disabled» instead of «23 417 pixels differ». Text is not compared: a name or a total differs between two healthy runs, and that noise is why pixel diffing gets switched off. |
67
+ | `qa_failure_gallery` | **How this screen looks when everything goes wrong** — empty, 500 with the server's own words, a held request, 403, a dropped connection — as one self-contained HTML sheet. It CAUSES the failures rather than waiting for them, and always removes the rules afterwards. |
68
+ | `qa_collect` | **A table instead of a page.** Name which nodes (`selector` / `role` / `nameContains`, scoped by `within`) and which properties to `take` — `text`, `value`, `name`, `role`, `tag`, `selector`, `href`, `state`, `geometry`, plus `attr:<name>` and `style:<property>` — and the answer is that table, once. «Every row with its price», «every input with what it holds» is one call here and a dozen with a snapshot plus a read per element; `value` is what a control HOLDS, which a snapshot cannot tell you. A property nobody can supply is named back rather than dropped. |
69
+ | `qa_watch` | **Watches for a few seconds** — the one question the other verbs cannot answer. Everything else reports NOW, so a toast that appears and leaves, a request fired by a timer, a late row or a live-region announcement are invisible to a snapshot taken before or after them, and polling misses the gaps between looks. `op:"start"` with `what`: `mutations` \| `network` \| `console` \| `announcements`; then `op:"read"` for the buffer. **Every watch expires by itself** — `ttlMs` is capped at two minutes and there is no «until stopped», because a watch left running is silent and the readings taken after it are the ones that go wrong. Observes only: no consent beyond the bridge. |
70
+ | `qa_gap` | **What the bridge could not do**, in one sentence, appended to `~/.qiksy/gaps.jsonl`. The sanctioned way out of a dead end — the unsanctioned one is a quiet switch to another browser driver, which has none of your logins and ends the run's meaning. `op:"list"` reads them back. |
63
71
  | `qa_snapshot` | A full-page accessibility snapshot — interactive elements + landmarks with their roles, labels and a `ref` you can pass to `qa_click`/`qa_type`/`qa_press` instead of a CSS selector. Read-only. **One snapshot owns its refs**: taking a new one renumbers them, so `e12` from an earlier snapshot may now be a different control and the click will succeed on the wrong thing — keep only the latest snapshot's refs, or target by `name`. **Narrow it** on a component-heavy app: `fields_only` (form controls only), `roles: ["button"]`, `match` on the accessible name, `within: "<selector of a container>"` for one dialog or wizard step, `limit`, `format: "tree"` for about half the bytes. Filtering happens on the way out, so the refs you get back are as valid as the full tree's. |
64
72
 
73
+
74
+ **Asking a reply for the fields you want.** The big reads (`qa_snapshot`, `qa_export`, `qa_findings`,
75
+ `qa_axe`, `qa_run_audit`, and both new verbs) take `select` — keep only these field paths — and
76
+ `omit` — drop these: `qa_snapshot { select: ["nodes[].name", "nodes[].ref"] }`. Dots walk into
77
+ objects, and a path through a list applies to every entry while keeping it a list, so anything you
78
+ already knew about the reply stays true. A path that matches nothing is reported back with the field
79
+ names that *are* there, so a mistyped field costs one call rather than a bigger request. Paths only:
80
+ no expressions and no code — the caller names a field, exactly as every verb here names an element
81
+ rather than a program.
82
+
65
83
  **Command** — drive Qiksy's own UI / analysis (never the app under test; all accept `tabId`):
66
84
 
67
85
  | Tool | What it does |
package/diff.mjs ADDED
@@ -0,0 +1,73 @@
1
+ // src/content/engine/color.ts
2
+ function canvasNormaliser() {
3
+ let ctx;
4
+ return (css) => {
5
+ if (ctx === void 0) {
6
+ try {
7
+ ctx = document.createElement("canvas").getContext("2d");
8
+ } catch {
9
+ ctx = null;
10
+ }
11
+ }
12
+ if (!ctx) return null;
13
+ const read = (seed) => {
14
+ ctx.fillStyle = seed;
15
+ ctx.fillStyle = css;
16
+ return String(ctx.fillStyle);
17
+ };
18
+ const fromBlack = read("#000000");
19
+ const fromWhite = read("#ffffff");
20
+ return fromBlack === fromWhite ? fromBlack : null;
21
+ };
22
+ }
23
+
24
+ // src/content/engine/a11y.ts
25
+ var normalise = canvasNormaliser();
26
+
27
+ // src/content/page-state.ts
28
+ var MOVE_THRESHOLD = 2;
29
+ function diffStates(before, after) {
30
+ const bySelector = new Map(before.nodes.map((n) => [n.selector, n]));
31
+ const byName = new Map(before.nodes.filter((n) => n.name).map((n) => [`${n.role}|${n.name}`, n]));
32
+ const usedBefore = /* @__PURE__ */ new Set();
33
+ const changed = [];
34
+ const appeared = [];
35
+ let unchanged = 0;
36
+ for (const now of after.nodes) {
37
+ const was = bySelector.get(now.selector) ?? (now.name ? byName.get(`${now.role}|${now.name}`) : void 0);
38
+ if (!was) {
39
+ appeared.push({ what: now.name || now.role, selector: now.selector });
40
+ continue;
41
+ }
42
+ usedBefore.add(was);
43
+ const diffs = [];
44
+ const dx = now.box.x - was.box.x;
45
+ const dy = now.box.y - was.box.y;
46
+ if (Math.abs(dx) > MOVE_THRESHOLD || Math.abs(dy) > MOVE_THRESHOLD) {
47
+ diffs.push(`moved ${dx ? `${dx > 0 ? dx + "px right" : -dx + "px left"}` : ""}${dx && dy ? " and " : ""}${dy ? `${dy > 0 ? dy + "px down" : -dy + "px up"}` : ""}`);
48
+ }
49
+ if (Math.abs(now.box.w - was.box.w) > MOVE_THRESHOLD) diffs.push(`width ${was.box.w} \u2192 ${now.box.w}`);
50
+ if (Math.abs(now.box.h - was.box.h) > MOVE_THRESHOLD) diffs.push(`height ${was.box.h} \u2192 ${now.box.h}`);
51
+ if (now.state !== was.state) diffs.push(`state "${was.state || "none"}" \u2192 "${now.state || "none"}"`);
52
+ for (const [k, v] of Object.entries(now.styles)) {
53
+ if (was.styles[k] !== void 0 && was.styles[k] !== v) diffs.push(`${k} ${was.styles[k]} \u2192 ${v}`);
54
+ }
55
+ if (diffs.length) changed.push({ what: now.name || now.role, selector: now.selector, changes: diffs });
56
+ else unchanged++;
57
+ }
58
+ const disappeared = before.nodes.filter((n) => !usedBefore.has(n)).map((n) => ({ what: n.name || n.role, selector: n.selector }));
59
+ const matched = after.nodes.length - appeared.length;
60
+ const unmatched = after.nodes.length > 10 && matched < after.nodes.length * 0.4 ? `only ${matched} of ${after.nodes.length} controls could be matched between the two states \u2014 the page is probably re-rendered from scratch (a virtualised list, new React keys). Read the list below as \xABwhat is there now\xBB, not as \xABwhat changed\xBB.` : void 0;
61
+ return {
62
+ ok: true,
63
+ appeared,
64
+ disappeared,
65
+ changed,
66
+ unchanged,
67
+ ...unmatched ? { unmatched } : {},
68
+ note: "Text content is deliberately NOT compared: a name, a total or a timestamp differs between two healthy runs, and reporting those is the noise that gets visual diffing switched off. Movements under 2px are the browser rounding a layout, not a change."
69
+ };
70
+ }
71
+ export {
72
+ diffStates
73
+ };
package/gallery.mjs ADDED
@@ -0,0 +1,141 @@
1
+ /**
2
+ * HOW THIS SCREEN LOOKS WHEN EVERYTHING GOES WRONG.
3
+ *
4
+ * The states nobody designs and nobody screenshots: the empty list, the 500 with the server's own
5
+ * words in it, the spinner that never ends, the connection that drops mid-submit. A tester does
6
+ * this by hand before every release — half an hour a screen — and a designer usually has never
7
+ * seen their own error state at all.
8
+ *
9
+ * Three of the six people asked about this independently proposed it, and for the same reason:
10
+ * every other capture tool can only PHOTOGRAPH a failure that happens to occur. This one can
11
+ * CAUSE it, because the mocks and the camera are the same product. That is not a feature gap, it
12
+ * is a category difference — and it costs almost nothing to build, because both halves already
13
+ * exist and this file is only the recipe that walks them.
14
+ *
15
+ * THE ONE RULE THAT MATTERS: every path out of here clears the mocks. A forgotten rule outlives
16
+ * the reason it was set, and the next thing anybody measures on that tab is a lie they cannot see.
17
+ * The product already learned this once; a loop that sets rules in sequence is the easiest place
18
+ * in the whole codebase to forget it, so the clearing is in a `finally` and is also asserted by
19
+ * the stand.
20
+ */
21
+
22
+ /** The failures worth having a picture of, and what each one is FOR. */
23
+ export const STATES = [
24
+ {
25
+ key: 'empty',
26
+ title: 'Empty',
27
+ why: 'the list came back with nothing in it — the state that is designed last and shipped broken most often',
28
+ rule: { status: 200, body: '{"data":[],"items":[],"results":[],"total":0}' },
29
+ },
30
+ {
31
+ key: 'server-error',
32
+ title: 'Server error (500)',
33
+ why: 'the back end fell over and said why — does the app show its words, or its own guess?',
34
+ rule: { status: 500, body: '{"error":"upstream timeout","message":"The request could not be completed."}' },
35
+ },
36
+ {
37
+ key: 'forbidden',
38
+ title: 'Forbidden (403)',
39
+ why: 'the session is alive but this user may not — the state that usually renders as an empty page with no explanation',
40
+ rule: { status: 403, body: '{"error":"forbidden","message":"You do not have access to this resource."}' },
41
+ },
42
+ {
43
+ key: 'slow',
44
+ title: 'Slow (holding for 8s)',
45
+ why: 'the answer is coming but has not arrived — this is the picture of the loading state, which is otherwise impossible to catch by hand',
46
+ rule: { status: 200, body: '{"data":[]}', delayMs: 8000 },
47
+ /* Photographed WHILE it is held, not after: the whole point is the spinner. */
48
+ shootAfterMs: 2500,
49
+ },
50
+ {
51
+ key: 'dropped',
52
+ title: 'Connection dropped',
53
+ why: 'the request failed at the transport level — no status, no body, the case most apps forget entirely',
54
+ rule: { fail: true },
55
+ },
56
+ ];
57
+
58
+ export const STATE_KEYS = STATES.map((s) => s.key);
59
+
60
+ const esc = (s) =>
61
+ String(s ?? '')
62
+ .replace(/&/g, '&amp;')
63
+ .replace(/</g, '&lt;')
64
+ .replace(/>/g, '&gt;')
65
+ .replace(/"/g, '&quot;');
66
+
67
+ /**
68
+ * ONE FILE THAT OPENS IN A BROWSER, and that is deliberate: the person who needs to see this —
69
+ * a designer, a product owner, a client — does not have the extension and never will. Pictures are
70
+ * embedded rather than linked, because a folder of files loses its pictures the moment it is
71
+ * forwarded.
72
+ */
73
+ export function renderGallery({ url, shots, build, receipt, at }) {
74
+ const cards = shots
75
+ .map((s) => {
76
+ const img = s.dataUri
77
+ ? `<img src="${s.dataUri}" alt="${esc(s.title)}">`
78
+ : `<div class="missing">no picture — ${esc(s.error || 'the shot did not come back')}</div>`;
79
+ return `<figure class="card${s.dataUri ? '' : ' bad'}">
80
+ <figcaption>
81
+ <h2>${esc(s.title)}</h2>
82
+ <p class="why">${esc(s.why)}</p>
83
+ <p class="rule">answered with: <code>${esc(s.ruleText)}</code></p>
84
+ </figcaption>
85
+ ${img}
86
+ </figure>`;
87
+ })
88
+ .join('\n');
89
+
90
+ const hidden = (receipt?.hidden || []).map((h) => `${esc(h.what)} (${h.count})`).join(' · ') || '—';
91
+ const notIncluded = (receipt?.notIncluded || []).map((n) => `<li>${esc(n)}</li>`).join('') || '';
92
+
93
+ return `<!doctype html>
94
+ <html lang="en">
95
+ <head>
96
+ <meta charset="utf-8">
97
+ <title>Failure states — ${esc(url)}</title>
98
+ <style>
99
+ :root { color-scheme: light dark; }
100
+ body { font: 14px/1.5 -apple-system, Segoe UI, Roboto, sans-serif; margin: 0; padding: 28px 32px 60px; background: #f6f7fb; color: #101828; }
101
+ h1 { font-size: 19px; margin: 0 0 2px; }
102
+ .sub { color: #667085; margin: 0 0 6px; }
103
+ .stamp { color: #667085; font-size: 12px; margin: 0 0 24px; }
104
+ .stamp code { background: #eceff4; padding: 1px 5px; border-radius: 4px; }
105
+ .card { margin: 0 0 28px; background: #fff; border: 1px solid #e4e7ec; border-radius: 12px; overflow: hidden; }
106
+ .card.bad { border-color: #fda29b; }
107
+ figcaption { padding: 14px 18px; border-bottom: 1px solid #f2f4f7; }
108
+ h2 { font-size: 15px; margin: 0 0 2px; }
109
+ .why { margin: 0 0 6px; color: #475467; }
110
+ .rule { margin: 0; font-size: 12px; color: #667085; }
111
+ code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
112
+ img { display: block; width: 100%; height: auto; }
113
+ .missing { padding: 26px 18px; color: #b42318; }
114
+ footer { margin-top: 30px; padding-top: 16px; border-top: 1px solid #e4e7ec; color: #667085; font-size: 12px; max-width: 760px; }
115
+ footer ul { margin: 6px 0 0; padding-left: 18px; }
116
+ @media (prefers-color-scheme: dark) {
117
+ body { background: #14161a; color: #e6e8ec; }
118
+ .card { background: #1b1e24; border-color: #2b2f37; }
119
+ figcaption { border-color: #2b2f37; }
120
+ .why { color: #aeb4bf; } .sub, .stamp, footer { color: #98a0ad; }
121
+ .stamp code { background: #262a31; }
122
+ }
123
+ </style>
124
+ </head>
125
+ <body>
126
+ <h1>How this screen looks when things go wrong</h1>
127
+ <p class="sub">${esc(url)}</p>
128
+ <p class="stamp">Taken ${esc(at)}${build?.viewport ? ` · window ${build.viewport.w}×${build.viewport.h} at ${build.viewport.dpr}× · ${esc(build.colourScheme)} scheme` : ''}${
129
+ build?.scripts?.length ? ` · running <code>${esc(build.scripts.slice(0, 3).join(' '))}</code>` : ''
130
+ }</p>
131
+ ${cards}
132
+ <footer>
133
+ <p><b>Each picture was caused, not waited for.</b> The app was answered from a rule instead of by its server; the rule is printed under every state. The rules were removed when the sheet was finished — a rule left behind would make the next measurement on this tab wrong.</p>
134
+ <p><b>Covered before every frame:</b> ${hidden}.</p>
135
+ ${notIncluded ? `<p><b>Not in this file at all:</b></p><ul>${notIncluded}</ul>` : ''}
136
+ <p>This says what was hidden, not that the file is safe to send — whatever was on screen is in the pictures. Look before you forward it.</p>
137
+ </footer>
138
+ </body>
139
+ </html>
140
+ `;
141
+ }
package/gaps.mjs ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * WHAT THE BRIDGE COULD NOT DO — written down instead of lost in a transcript.
3
+ *
4
+ * Owner, 16.09.2026, asking whether the bridge should become flexible enough to do anything: the
5
+ * honest answer is that no tool does anything, and the ones that try end up executing whatever
6
+ * they are handed. What a tool CAN do is say precisely what it could not do, and remember it.
7
+ *
8
+ * So this is the other half of flexibility, and the half that survives a Web Store review: the
9
+ * agent hits a wall, names it in one call, and the wall goes into a file the human reads. The
10
+ * flexibility then happens where it is safe — in the next version, with a verb that has a name
11
+ * and a stand — rather than at runtime inside somebody's browser.
12
+ *
13
+ * Why a file and not a counter: the interesting part of a gap is the SENTENCE. «qa_read cannot see
14
+ * inside the iframe on the payment step» is a feature request; «iframe: 7» is a statistic nobody
15
+ * can act on. One line of JSON per gap, appended, never rewritten — the log is the record, and a
16
+ * record a process can rewrite is a record it can lose.
17
+ */
18
+ import { appendFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs';
19
+ import { dirname, join } from 'node:path';
20
+ import { homedir } from 'node:os';
21
+
22
+ export const GAPS_FILE = process.env.QIKSY_GAPS_FILE || join(homedir(), '.qiksy', 'gaps.jsonl');
23
+
24
+ const clip = (v, n) => (typeof v === 'string' ? v.replace(/\s+/g, ' ').trim().slice(0, n) : undefined);
25
+
26
+ /**
27
+ * Record one gap. `at` is passed in rather than read from the clock so the caller owns the
28
+ * timestamp — and so this file can be tested without one.
29
+ */
30
+ export function recordGap(entry, at) {
31
+ const wanted = clip(entry?.wanted, 400);
32
+ if (!wanted) return { ok: false, error: 'say what you wanted the bridge to do, in one sentence (`wanted`)' };
33
+ const row = {
34
+ at: at ?? new Date().toISOString(),
35
+ wanted,
36
+ verb: clip(entry?.verb, 60) || null,
37
+ tried: Array.isArray(entry?.tried) ? entry.tried.slice(0, 8).map((t) => clip(t, 120)).filter(Boolean) : [],
38
+ /* The address is the difference between a report somebody can reproduce and a wish. Stripped
39
+ of its query the same way every other artefact here is: a gap report is a document that
40
+ leaves this machine, and a one-time token in a URL must not ride along. */
41
+ url: clip(String(entry?.url ?? '').split('?')[0], 200) || null,
42
+ blocked: entry?.blocked === true,
43
+ };
44
+ try {
45
+ mkdirSync(dirname(GAPS_FILE), { recursive: true });
46
+ appendFileSync(GAPS_FILE, JSON.stringify(row) + '\n', 'utf8');
47
+ } catch (e) {
48
+ return { ok: false, error: `could not write ${GAPS_FILE}: ${String(e?.message || e).slice(0, 120)}` };
49
+ }
50
+ return { ok: true, recorded: row, file: GAPS_FILE };
51
+ }
52
+
53
+ /** Read the log back, newest first. A malformed line is skipped rather than taking the list down. */
54
+ export function listGaps(limit = 50) {
55
+ if (!existsSync(GAPS_FILE)) return [];
56
+ let text = '';
57
+ try {
58
+ text = readFileSync(GAPS_FILE, 'utf8');
59
+ } catch {
60
+ return [];
61
+ }
62
+ const out = [];
63
+ for (const line of text.split('\n')) {
64
+ if (!line.trim()) continue;
65
+ try {
66
+ out.push(JSON.parse(line));
67
+ } catch {
68
+ /* a half-written line from a killed process is not a reason to lose the rest */
69
+ }
70
+ }
71
+ return out.reverse().slice(0, Math.max(1, Math.min(500, limit)));
72
+ }
73
+
74
+ /**
75
+ * THE INVITATION THAT RIDES A REFUSAL.
76
+ *
77
+ * A verb that says no and nothing else teaches the agent to go round it — usually by switching to
78
+ * another driver, which is the failure this project spends the most words preventing. The same
79
+ * refusal carrying «and if the bridge genuinely cannot, say so with qa_gap» turns a dead end into
80
+ * one call. Rate-limited by the caller: an invitation on every refusal is noise, and noise is how
81
+ * a good instruction stops being read.
82
+ */
83
+ export function gapInvite(verb) {
84
+ return {
85
+ ifTheBridgeCannot: `If this is not a mistake in the call but something the bridge genuinely cannot do, record it: qa_gap { verb: "${verb}", wanted: "<one sentence>" }. It goes into a file the human reads, and that is how the missing verb gets built.`,
86
+ };
87
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qiksy-mcp",
3
- "version": "1.30.0",
3
+ "version": "1.41.0",
4
4
  "description": "Browser MCP server for the Chrome tab you already have open — your session, your logins. Gives Claude Code, Cursor, Codex and VS Code the live page: findings, forms, failed requests with server bodies.",
5
5
  "keywords": [
6
6
  "mcp",
@@ -31,6 +31,11 @@
31
31
  "files": [
32
32
  "server.mjs",
33
33
  "widgets.mjs",
34
+ "project.mjs",
35
+ "gaps.mjs",
36
+ "gallery.mjs",
37
+ "svg-shot.mjs",
38
+ "diff.mjs",
34
39
  "README.md",
35
40
  "AGENT-GUIDE.md"
36
41
  ],
package/project.mjs ADDED
@@ -0,0 +1,158 @@
1
+ /**
2
+ * ORDERING THE FIELDS OF A REPLY — the cheapest kind of flexibility there is.
3
+ *
4
+ * Owner, 16.09.2026: «стоит ли делать мост гибким, чтобы Клод мог структурно его переделывать?»
5
+ * The answer this file implements is the half of that question which costs nothing and risks
6
+ * nothing. Almost every «the bridge cannot do this» turns out to be «the verb did not hand back
7
+ * something it had already measured», or the opposite — it handed back a whole page when three
8
+ * fields were wanted and the caller paid for the rest in context.
9
+ *
10
+ * So a reply can be ASKED FOR: `select` keeps only these paths, `omit` drops these. No new verb,
11
+ * no new capability, nothing executed — the caller names fields, exactly as every other verb here
12
+ * names an element rather than a program.
13
+ *
14
+ * WHY THIS IS NOT «FLEXIBILITY» IN THE DANGEROUS SENSE. A path is a closed, enumerable thing:
15
+ * letters, digits, dots, `[]`. It cannot compute, branch or loop, so shipping it is not shipping
16
+ * an interpreter for somebody else's code — the line the Web Store draws, and the line this
17
+ * product's whole design follows.
18
+ */
19
+
20
+ /** A path is `a.b.c`; `[]` maps over an array: `fields[].name` takes `name` from every entry. */
21
+ const STEP = /^[A-Za-z0-9_$-]+(\[\])?$/;
22
+
23
+ export function validPath(path) {
24
+ if (typeof path !== 'string' || !path.length || path.length > 200) return false;
25
+ return path.split('.').every((s) => STEP.test(s));
26
+ }
27
+
28
+ const isPlain = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
29
+
30
+ /**
31
+ * THE PROJECTION KEEPS THE SHAPE IT PROJECTS.
32
+ *
33
+ * The first cut read each path separately and wrote it into a fresh object, which turned
34
+ * `fields[].name` into `{ fields: { name: [...] } }` — an answer in a shape the original never
35
+ * had, so anything the caller knew about the reply stopped being true. Paths are folded into one
36
+ * tree instead and the tree is walked over the value: a list stays a list of the same length, an
37
+ * object keeps its keys, and only the unasked-for fields go.
38
+ */
39
+ const LEAF = Symbol('leaf');
40
+
41
+ function buildTree(paths) {
42
+ const root = {};
43
+ for (const path of paths) {
44
+ let node = root;
45
+ const steps = path.split('.');
46
+ steps.forEach((step, i) => {
47
+ const key = step.endsWith('[]') ? step.slice(0, -2) : step;
48
+ const last = i === steps.length - 1;
49
+ if (last) node[key] = LEAF;
50
+ else {
51
+ if (node[key] === LEAF || node[key] === undefined) node[key] = {};
52
+ node = node[key];
53
+ }
54
+ });
55
+ }
56
+ return root;
57
+ }
58
+
59
+ /** Walk a value through the tree. Returns `undefined` when nothing on this branch was there. */
60
+ function applyTree(value, node, hit) {
61
+ if (node === LEAF) {
62
+ if (value !== undefined) hit.any = true;
63
+ return value;
64
+ }
65
+ if (value == null) return undefined;
66
+ if (Array.isArray(value)) {
67
+ /* A list stays a list — and an entry that held none of the asked-for fields is dropped rather
68
+ than left as an empty object, so `fields[].name` over a mixed list answers with the ones
69
+ that have a name and not with a row of blanks. */
70
+ const out = value.map((v) => applyTree(v, node, hit)).filter((v) => v !== undefined);
71
+ return out.length ? out : undefined;
72
+ }
73
+ if (!isPlain(value)) return undefined;
74
+ const out = {};
75
+ let got = false;
76
+ for (const [key, child] of Object.entries(node)) {
77
+ const sub = applyTree(value[key], child, hit);
78
+ if (sub !== undefined) {
79
+ out[key] = sub;
80
+ got = true;
81
+ }
82
+ }
83
+ return got ? out : undefined;
84
+ }
85
+
86
+ /**
87
+ * Keep only the named paths. A path that matches nothing is REPORTED rather than dropped in
88
+ * silence: an agent that mistyped a field name and got `{}` back learns nothing, and the next
89
+ * thing it does is call again with a bigger request.
90
+ */
91
+ export function pickPaths(value, paths) {
92
+ const bad = paths.filter((p) => !validPath(p));
93
+ if (bad.length) return { ok: false, error: `not a field path: ${bad.slice(0, 4).join(', ')}` };
94
+ if (value == null || typeof value !== 'object') return { ok: true, value, missed: [] };
95
+ /* Each path is tried on its own FIRST, only to find out which ones matched nothing; the answer
96
+ itself is then built in one walk so its shape survives. */
97
+ const missed = [];
98
+ for (const path of paths) {
99
+ const hit = { any: false };
100
+ applyTree(value, buildTree([path]), hit);
101
+ if (!hit.any) missed.push(path);
102
+ }
103
+ const out = applyTree(value, buildTree(paths), { any: false });
104
+ return { ok: true, value: out === undefined ? {} : out, missed };
105
+ }
106
+
107
+ /** Drop the named paths, keeping everything else. */
108
+ export function omitPaths(value, paths) {
109
+ const bad = paths.filter((p) => !validPath(p));
110
+ if (bad.length) return { ok: false, error: `not a field path: ${bad.slice(0, 4).join(', ')}` };
111
+ const strip = (v, steps) => {
112
+ if (Array.isArray(v)) return v.map((x) => strip(x, steps));
113
+ if (!isPlain(v)) return v;
114
+ const [head, ...rest] = steps;
115
+ const key = head.endsWith('[]') ? head.slice(0, -2) : head;
116
+ if (!(key in v)) return v;
117
+ const copy = { ...v };
118
+ if (!rest.length) delete copy[key];
119
+ else copy[key] = strip(copy[key], rest);
120
+ return copy;
121
+ };
122
+ let out = value;
123
+ for (const path of paths) out = strip(out, path.split('.'));
124
+ return { ok: true, value: out };
125
+ }
126
+
127
+ /**
128
+ * The one entry point the verbs use. Returns the reply as asked for, plus a `projected` line
129
+ * saying what was done — a reply that has been trimmed and does not say so is a reply nobody can
130
+ * reason about later, and this project has paid for that mistake in other places already.
131
+ */
132
+ export function project(value, { select, omit } = {}) {
133
+ const sel = Array.isArray(select) ? select.filter(Boolean).map(String) : null;
134
+ const omt = Array.isArray(omit) ? omit.filter(Boolean).map(String) : null;
135
+ if (!sel?.length && !omt?.length) return value;
136
+ if (value == null || typeof value !== 'object') return value;
137
+
138
+ let out = value;
139
+ const note = {};
140
+ if (omt?.length) {
141
+ const r = omitPaths(out, omt);
142
+ if (!r.ok) return { ...value, projectionError: r.error };
143
+ out = r.value;
144
+ note.omitted = omt;
145
+ }
146
+ if (sel?.length) {
147
+ const r = pickPaths(out, sel);
148
+ if (!r.ok) return { ...value, projectionError: r.error };
149
+ out = r.value;
150
+ note.selected = sel;
151
+ if (r.missed.length) {
152
+ note.notThere = r.missed;
153
+ /* The fields that ARE there, so a mistyped name is one call from being right. */
154
+ note.available = Object.keys(isPlain(value) ? value : {}).slice(0, 30);
155
+ }
156
+ }
157
+ return { ...out, projected: note };
158
+ }