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 +25 -0
- package/README.md +20 -2
- package/diff.mjs +73 -0
- package/gallery.mjs +141 -0
- package/gaps.mjs +87 -0
- package/package.json +6 -1
- package/project.mjs +158 -0
- package/server.mjs +1026 -28
- package/svg-shot.mjs +95 -0
- package/widgets.mjs +42 -2
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.
|
|
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
|
-
|
|
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, '&')
|
|
63
|
+
.replace(/</g, '<')
|
|
64
|
+
.replace(/>/g, '>')
|
|
65
|
+
.replace(/"/g, '"');
|
|
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.
|
|
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
|
+
}
|