@reticlehq/server 2.8.0 → 2.9.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/SKILL.md +130 -813
- package/dist/bridge/bridge.d.ts +23 -0
- package/dist/bridge/bridge.js +80 -2
- package/dist/bridge/bridge.test-harness.d.ts +5 -0
- package/dist/bridge/bridge.test-harness.js +6 -4
- package/dist/capsule/causal-summary.d.ts +16 -1
- package/dist/capsule/causal-summary.js +2 -1
- package/dist/cli/cli-doctor.js +41 -13
- package/dist/cli/cli-flow-commands.js +8 -8
- package/dist/cli/cli-launch.d.ts +0 -17
- package/dist/cli/cli-launch.js +0 -28
- package/dist/cli/cli-port.d.ts +24 -0
- package/dist/cli/cli-port.js +66 -35
- package/dist/cli/cli-verify.d.ts +21 -0
- package/dist/cli/cli-verify.js +45 -1
- package/dist/cli/config-discovery.d.ts +35 -0
- package/dist/cli/config-discovery.js +163 -0
- package/dist/cli/doctor-daemon-line.js +4 -1
- package/dist/cli/doctor-project-line.d.ts +37 -0
- package/dist/cli/doctor-project-line.js +35 -0
- package/dist/cli/doctor-rows.d.ts +51 -0
- package/dist/cli/doctor-rows.js +54 -0
- package/dist/cli/doctor-sessions-line.js +6 -3
- package/dist/cli/drive-attach.d.ts +99 -0
- package/dist/cli/drive-attach.js +203 -0
- package/dist/cli/drive-command.d.ts +13 -0
- package/dist/cli/drive-command.js +103 -0
- package/dist/cli/open-note.d.ts +20 -0
- package/dist/cli/open-note.js +37 -0
- package/dist/cli/status-next-action.d.ts +37 -0
- package/dist/cli/status-next-action.js +61 -0
- package/dist/cli.js +65 -70
- package/dist/events/contradictions.d.ts +32 -0
- package/dist/events/contradictions.js +9 -5
- package/dist/events/declared.d.ts +45 -0
- package/dist/events/declared.js +73 -0
- package/dist/events/echo-mismatch.d.ts +8 -2
- package/dist/events/echo-mismatch.js +33 -5
- package/dist/events/event-filters.d.ts +1 -1
- package/dist/events/event-filters.js +7 -4
- package/dist/events/predicate-eval.d.ts +17 -91
- package/dist/events/predicate-eval.js +85 -326
- package/dist/events/predicate-parse.js +31 -3
- package/dist/events/predicate-route.d.ts +32 -0
- package/dist/events/predicate-route.js +102 -0
- package/dist/events/predicate-schema.d.ts +99 -0
- package/dist/events/predicate-schema.js +309 -0
- package/dist/events/predicate.d.ts +8 -0
- package/dist/events/predicate.js +127 -5
- package/dist/flows/annotate-tools.js +3 -0
- package/dist/flows/annotate.js +18 -4
- package/dist/flows/coverage.d.ts +38 -5
- package/dist/flows/coverage.js +30 -4
- package/dist/flows/decision.d.ts +15 -0
- package/dist/flows/decision.js +1 -1
- package/dist/flows/flow-replay-run.js +8 -1
- package/dist/flows/flow-tools.js +9 -1
- package/dist/flows/replay.js +3 -0
- package/dist/honesty/accepted-write.d.ts +3 -0
- package/dist/honesty/accepted-write.js +8 -3
- package/dist/honesty/already-true.js +15 -0
- package/dist/honesty/blind-spots.js +4 -0
- package/dist/honesty/honesty.d.ts +11 -0
- package/dist/honesty/honesty.js +1 -0
- package/dist/honesty/uncaptured-bodies.d.ts +4 -2
- package/dist/honesty/uncaptured-bodies.js +9 -2
- package/dist/honesty/unread-outcome.d.ts +8 -0
- package/dist/honesty/unread-outcome.js +36 -9
- package/dist/honesty/unsettled.d.ts +9 -0
- package/dist/honesty/unsettled.js +7 -3
- package/dist/honesty/verified.d.ts +29 -3
- package/dist/honesty/verified.js +51 -8
- package/dist/http-server.d.ts +19 -0
- package/dist/http-server.js +202 -4
- package/dist/impact/impact-recorder.d.ts +23 -0
- package/dist/impact/impact-recorder.js +76 -0
- package/dist/impact/impact-store.d.ts +47 -0
- package/dist/impact/impact-store.js +171 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +44 -10
- package/dist/init/agent-rules.d.ts +20 -0
- package/dist/init/agent-rules.js +36 -0
- package/dist/init/astro-patch.js +27 -1
- package/dist/init/confirm.d.ts +21 -3
- package/dist/init/confirm.js +32 -5
- package/dist/init/connect-steps.d.ts +2 -0
- package/dist/init/connect-steps.js +50 -0
- package/dist/init/csp-check.d.ts +38 -0
- package/dist/init/csp-check.js +92 -0
- package/dist/init/csp-doctor.d.ts +25 -0
- package/dist/init/csp-doctor.js +49 -0
- package/dist/init/cursor.js +6 -9
- package/dist/init/detect.d.ts +8 -0
- package/dist/init/detect.js +33 -3
- package/dist/init/mcp-clients.d.ts +1 -0
- package/dist/init/mcp-clients.js +51 -7
- package/dist/init/mcp.d.ts +15 -0
- package/dist/init/mcp.js +19 -0
- package/dist/init/plan-framework.d.ts +10 -0
- package/dist/init/plan-framework.js +25 -1
- package/dist/init/plan.d.ts +7 -2
- package/dist/init/plan.js +66 -30
- package/dist/init/run.js +11 -5
- package/dist/init/slash-command.d.ts +1 -1
- package/dist/init/slash-command.js +12 -4
- package/dist/init/snippets.d.ts +1 -1
- package/dist/init/snippets.js +35 -7
- package/dist/init/vite-config.js +24 -2
- package/dist/input/network-mock-tools.js +8 -1
- package/dist/input/scroll-find.d.ts +6 -0
- package/dist/input/scroll-find.js +17 -2
- package/dist/input/scroll-tools.js +4 -0
- package/dist/journal/attach-journal.d.ts +9 -1
- package/dist/journal/attach-journal.js +13 -2
- package/dist/journal/journal-query.d.ts +13 -0
- package/dist/journal/journal-query.js +28 -3
- package/dist/journal/workspace-gitignore.d.ts +10 -0
- package/dist/journal/workspace-gitignore.js +59 -0
- package/dist/mcp/mcp-proxy.d.ts +5 -66
- package/dist/mcp/mcp-proxy.js +180 -163
- package/dist/mcp/mcp.d.ts +10 -1
- package/dist/mcp/mcp.js +47 -27
- package/dist/mcp/proxy-handshake.d.ts +82 -1
- package/dist/mcp/proxy-handshake.js +109 -2
- package/dist/mcp/proxy-lifecycle.d.ts +10 -1
- package/dist/mcp/proxy-lifecycle.js +11 -2
- package/dist/mcp/proxy-log.d.ts +63 -0
- package/dist/mcp/proxy-log.js +132 -0
- package/dist/mcp/server-instructions.d.ts +17 -0
- package/dist/mcp/server-instructions.js +56 -0
- package/dist/mcp/shared-params.d.ts +27 -0
- package/dist/mcp/shared-params.js +36 -0
- package/dist/project/reticle-dir.d.ts +2 -0
- package/dist/project/reticle-dir.js +1 -0
- package/dist/session/connection-memory.d.ts +59 -0
- package/dist/session/connection-memory.js +108 -0
- package/dist/session/dev-command.d.ts +26 -0
- package/dist/session/dev-command.js +89 -0
- package/dist/session/dev-server-probe.d.ts +68 -0
- package/dist/session/dev-server-probe.js +102 -7
- package/dist/session/last-act.d.ts +26 -0
- package/dist/session/last-act.js +35 -1
- package/dist/session/live-control-tools.js +42 -5
- package/dist/session/no-session-diagnosis.d.ts +48 -2
- package/dist/session/no-session-diagnosis.js +176 -20
- package/dist/session/no-session-next-action.d.ts +46 -0
- package/dist/session/no-session-next-action.js +85 -0
- package/dist/session/no-session-watch.d.ts +40 -1
- package/dist/session/no-session-watch.js +165 -11
- package/dist/session/pending-commands.d.ts +12 -0
- package/dist/session/pending-commands.js +13 -1
- package/dist/session/session-manager.d.ts +4 -0
- package/dist/session/session-manager.js +35 -5
- package/dist/session/session.d.ts +10 -1
- package/dist/session/session.js +146 -5
- package/dist/telemetry/app-instrumented.d.ts +8 -0
- package/dist/telemetry/app-instrumented.js +10 -0
- package/dist/telemetry/bug-found.d.ts +18 -0
- package/dist/telemetry/bug-found.js +14 -0
- package/dist/telemetry/daemon-telemetry.js +24 -0
- package/dist/telemetry/install-source.d.ts +20 -0
- package/dist/telemetry/install-source.js +34 -0
- package/dist/telemetry/instrumentation-stall.d.ts +50 -0
- package/dist/telemetry/instrumentation-stall.js +90 -0
- package/dist/telemetry/session-metrics.d.ts +8 -0
- package/dist/telemetry/session-metrics.js +10 -0
- package/dist/telemetry/telemetry.d.ts +3 -1
- package/dist/telemetry/telemetry.js +13 -2
- package/dist/tools/act-tools.js +166 -29
- package/dist/tools/assert-verdict.js +32 -7
- package/dist/tools/browser-tools.js +10 -0
- package/dist/tools/dynamic-tools.js +16 -7
- package/dist/tools/error-recovery.js +9 -5
- package/dist/tools/invoke-tool.js +20 -3
- package/dist/tools/lease-hint.d.ts +30 -8
- package/dist/tools/lease-hint.js +65 -14
- package/dist/tools/lease-tools.d.ts +11 -0
- package/dist/tools/lease-tools.js +141 -96
- package/dist/tools/observe-tools.js +9 -3
- package/dist/tools/react-grace.d.ts +4 -0
- package/dist/tools/react-grace.js +6 -3
- package/dist/tools/read-tools.js +47 -1
- package/dist/tools/resolve-target.d.ts +33 -0
- package/dist/tools/resolve-target.js +53 -0
- package/dist/tools/settle-in-flight.d.ts +15 -0
- package/dist/tools/settle-in-flight.js +42 -2
- package/dist/tools/snapshot-delta.d.ts +3 -0
- package/dist/tools/snapshot-delta.js +67 -6
- package/dist/tools/tool-kit.d.ts +8 -0
- package/dist/tools/tool-surface.d.ts +60 -0
- package/dist/tools/tool-surface.js +85 -19
- package/dist/tools/tools.js +45 -4
- package/dist/tools/unadvertised-help.js +9 -6
- package/docs/README.md +1 -2
- package/docs/actions.mdx +41 -8
- package/docs/agent-cheatsheet.md +4 -2
- package/docs/architecture.md +6 -0
- package/docs/benchmarks.md +60 -12
- package/docs/best-practices.mdx +11 -6
- package/docs/capabilities.mdx +201 -54
- package/docs/cli/affected.mdx +6 -4
- package/docs/cli/capsules.mdx +9 -8
- package/docs/cli/cloud.mdx +30 -4
- package/docs/cli/doctor.mdx +41 -8
- package/docs/cli/drive.mdx +34 -15
- package/docs/cli/gate.mdx +6 -6
- package/docs/cli/help.mdx +8 -6
- package/docs/cli/hunt.mdx +18 -2
- package/docs/cli/identify.mdx +16 -2
- package/docs/cli/init.mdx +35 -8
- package/docs/cli/kill.mdx +5 -5
- package/docs/cli/license.mdx +1 -1
- package/docs/cli/open.mdx +6 -0
- package/docs/cli/restart.mdx +6 -6
- package/docs/cli/serve.mdx +10 -3
- package/docs/cli/status.mdx +29 -5
- package/docs/cli/stop.mdx +8 -0
- package/docs/cli/verify.mdx +20 -1
- package/docs/cli/version.mdx +1 -1
- package/docs/cli/watch.mdx +9 -2
- package/docs/cli.mdx +44 -22
- package/docs/debugging.md +3 -1
- package/docs/deploy-checks.md +30 -1
- package/docs/desktop.mdx +276 -80
- package/docs/docs.json +13 -12
- package/docs/enterprise.md +2 -0
- package/docs/faq.mdx +49 -35
- package/docs/fixtures.md +2 -0
- package/docs/flows.md +3 -1
- package/docs/for-agents.md +3 -3
- package/docs/frameworks.mdx +46 -7
- package/docs/gate-plan.md +4 -2
- package/docs/gates.md +7 -5
- package/docs/getting-started.md +53 -12
- package/docs/human-control.md +2 -0
- package/docs/index.mdx +43 -1
- package/docs/install-agentic.mdx +13 -4
- package/docs/install-manual.mdx +6 -4
- package/docs/instrumentation.mdx +7 -1
- package/docs/integration-patterns.md +3 -1
- package/docs/local-registry.md +4 -2
- package/docs/multi-agent-testing.md +3 -1
- package/docs/packages/babel-plugin.mdx +15 -6
- package/docs/packages/browser.mdx +2 -2
- package/docs/packages/core.mdx +26 -16
- package/docs/packages/electron.mdx +5 -7
- package/docs/packages/eslint-plugin.mdx +5 -3
- package/docs/packages/next.mdx +3 -5
- package/docs/packages/react.mdx +3 -3
- package/docs/packages/server.mdx +2 -2
- package/docs/packages/tauri.mdx +6 -6
- package/docs/packages/test.mdx +18 -7
- package/docs/packages/vite-plugin.mdx +4 -6
- package/docs/packages.mdx +14 -13
- package/docs/platform-integration.md +2 -0
- package/docs/predicates.mdx +76 -7
- package/docs/quickstart.mdx +51 -5
- package/docs/recipes.mdx +62 -22
- package/docs/skill-file.mdx +3 -1
- package/docs/state-management.mdx +2 -0
- package/docs/system-map.md +3 -1
- package/docs/telemetry-contract.md +16 -5
- package/docs/telemetry-events.mdx +63 -18
- package/docs/telemetry.md +8 -5
- package/docs/testing.md +35 -1
- package/docs/token-efficiency.md +8 -2
- package/docs/tools-act-and-wait.mdx +64 -14
- package/docs/tools-act-sequence.mdx +34 -17
- package/docs/tools-act.mdx +24 -11
- package/docs/tools-assert.mdx +69 -10
- package/docs/tools-console.mdx +11 -7
- package/docs/tools-inspect.mdx +11 -8
- package/docs/tools-navigate.mdx +41 -5
- package/docs/tools-network.mdx +42 -9
- package/docs/tools-observe.mdx +66 -10
- package/docs/tools-overview.mdx +26 -23
- package/docs/tools-query.mdx +52 -8
- package/docs/tools-session-and-feedback.mdx +30 -9
- package/docs/tools-sessions.mdx +39 -10
- package/docs/tools-snapshot.mdx +57 -3
- package/docs/tools-state.mdx +24 -1
- package/docs/tools-tools-and-run.mdx +40 -9
- package/docs/tools-wait-for.mdx +34 -15
- package/docs/troubleshooting.mdx +80 -10
- package/docs/usage.md +43 -21
- package/docs/vs-chrome-devtools-mcp.mdx +41 -0
- package/docs/vs-playwright-mcp.mdx +42 -2
- package/docs/vs-screenshots.mdx +42 -1
- package/docs/what-is-reticle.mdx +1 -1
- package/docs/why-reticle.mdx +3 -1
- package/package.json +5 -5
- package/docs/desktop-apps.md +0 -265
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `route` oracle. Split out of predicate-eval.ts, which was at the file-size backstop.
|
|
3
|
+
*
|
|
4
|
+
* Route is the one predicate with TWO sources of truth — a navigation inside the window and where the
|
|
5
|
+
* app is right now — and keeping that reconciliation in one place is what makes it readable.
|
|
6
|
+
*/
|
|
7
|
+
import { EventType, PredicateKind } from '@reticlehq/core';
|
|
8
|
+
import { str } from './predicate-eval.js';
|
|
9
|
+
/**
|
|
10
|
+
* Which fact answered a `route` predicate: a navigation inside the window, or where the app is now.
|
|
11
|
+
*
|
|
12
|
+
* Reported on every route result because the two are different claims. "It changed to /login" proves
|
|
13
|
+
* a redirect; "it was already /login" does not, and an agent asserting a post-login redirect has to
|
|
14
|
+
* be able to tell them apart. Silently conflating them would trade one wrong verdict for another.
|
|
15
|
+
*/
|
|
16
|
+
export const RouteDecidedBy = {
|
|
17
|
+
CHANGE: 'route-change',
|
|
18
|
+
CURRENT: 'current-route',
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* The session's live URL, split the way a route-change event already carries it.
|
|
22
|
+
*
|
|
23
|
+
* Absolute (that is what the session tracks), but a relative value is accepted rather than thrown
|
|
24
|
+
* away — an unreadable URL is not worth losing the fallback over.
|
|
25
|
+
*/
|
|
26
|
+
function readCurrentRoute(url) {
|
|
27
|
+
const parsed = URL.canParse(url) ? new URL(url) : undefined;
|
|
28
|
+
const pathname = parsed?.pathname ?? url;
|
|
29
|
+
const search = parsed?.search ?? '';
|
|
30
|
+
const hash = parsed?.hash ?? '';
|
|
31
|
+
return {
|
|
32
|
+
pathname,
|
|
33
|
+
full: `${pathname}${search}${hash}`,
|
|
34
|
+
decidedBy: RouteDecidedBy.CURRENT,
|
|
35
|
+
data: { pathname, search, hash, url, decidedBy: RouteDecidedBy.CURRENT },
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** Name the source in the prose too — the structured `decidedBy` is for the agent, this is for a log. */
|
|
39
|
+
function describeRouteSource(reading) {
|
|
40
|
+
return reading.decidedBy === RouteDecidedBy.CHANGE
|
|
41
|
+
? 'route changed to'
|
|
42
|
+
: 'current route (no route change in the window)';
|
|
43
|
+
}
|
|
44
|
+
export function evalRoute(events, p,
|
|
45
|
+
/**
|
|
46
|
+
* Where the app is RIGHT NOW, if the caller can say.
|
|
47
|
+
*
|
|
48
|
+
* Without it a route predicate could only ever be answered by a navigation inside the window, so
|
|
49
|
+
* "did the session survive a reload?" — which by definition produces no route change — and "we
|
|
50
|
+
* landed on /login" after a completed navigate were both a guaranteed false red, contradicted by
|
|
51
|
+
* `reticle_snapshot({ mode: 'status' }).route` one call later. Optional so a caller with no session
|
|
52
|
+
* (a replayed window) keeps the old answer instead of inventing a route.
|
|
53
|
+
*/
|
|
54
|
+
currentUrl) {
|
|
55
|
+
const routes = events.filter((e) => e.type === EventType.ROUTE_CHANGE);
|
|
56
|
+
const last = routes.at(-1);
|
|
57
|
+
const reading = last !== undefined
|
|
58
|
+
? {
|
|
59
|
+
pathname: str(last.data['pathname']) ?? str(last.data['to']) ?? '',
|
|
60
|
+
full: `${str(last.data['pathname']) ?? str(last.data['to']) ?? ''}${str(last.data['search']) ?? ''}${str(last.data['hash']) ?? ''}`,
|
|
61
|
+
decidedBy: RouteDecidedBy.CHANGE,
|
|
62
|
+
data: { ...last.data, decidedBy: RouteDecidedBy.CHANGE },
|
|
63
|
+
}
|
|
64
|
+
: currentUrl === undefined || 0 === currentUrl.length
|
|
65
|
+
? undefined
|
|
66
|
+
: readCurrentRoute(currentUrl);
|
|
67
|
+
if (reading === undefined) {
|
|
68
|
+
return {
|
|
69
|
+
pass: false,
|
|
70
|
+
failureReason: 'no route change observed',
|
|
71
|
+
observed: 'no route change in the window',
|
|
72
|
+
expected: `a route change to ${p.pathname ?? p.contains ?? 'any route'}`,
|
|
73
|
+
assertion: 'route.changed',
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const pathname = reading.pathname;
|
|
77
|
+
if (p.pathname !== undefined && pathname !== p.pathname) {
|
|
78
|
+
return {
|
|
79
|
+
pass: false,
|
|
80
|
+
failureReason: `${describeRouteSource(reading)} is '${pathname}', expected '${p.pathname}'`,
|
|
81
|
+
observed: `${describeRouteSource(reading)} '${pathname}'`,
|
|
82
|
+
expected: `route '${p.pathname}'`,
|
|
83
|
+
assertion: 'route.pathname',
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
// `contains` matches the WHOLE route — path + query + fragment — while `pathname` above stays an
|
|
87
|
+
// exact path match. A hash router keeps the entire route in the fragment, so matching pathname
|
|
88
|
+
// alone made `contains` unsatisfiable for every HashRouter app; that is the standard router for a
|
|
89
|
+
// packaged Electron/Tauri renderer, where an absolute pushState would rewrite the URL to a
|
|
90
|
+
// nonexistent file.
|
|
91
|
+
const fullRoute = reading.full;
|
|
92
|
+
if (p.contains !== undefined && !fullRoute.includes(p.contains)) {
|
|
93
|
+
return {
|
|
94
|
+
pass: false,
|
|
95
|
+
failureReason: `${describeRouteSource(reading)} '${fullRoute}' does not contain '${p.contains}'`,
|
|
96
|
+
observed: `${describeRouteSource(reading)} '${fullRoute}'`,
|
|
97
|
+
expected: `a route containing '${p.contains}'`,
|
|
98
|
+
assertion: 'route.contains',
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return { pass: true, evidence: reading.data };
|
|
102
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { ElementState, PredicateKind, type ElementDescriptor, type ElementQuery } from '@reticlehq/core';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
export type Predicate = {
|
|
4
|
+
kind: typeof PredicateKind.ELEMENT;
|
|
5
|
+
query: ElementQuery;
|
|
6
|
+
state?: ElementState;
|
|
7
|
+
absent?: boolean;
|
|
8
|
+
} | {
|
|
9
|
+
kind: typeof PredicateKind.TEXT;
|
|
10
|
+
contains: string;
|
|
11
|
+
visible?: boolean;
|
|
12
|
+
absent?: boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Restrict the match to a subtree, as a CSS selector or a ref — the same field, and the same
|
|
15
|
+
* meaning, as `scope` on an element query.
|
|
16
|
+
*
|
|
17
|
+
* Without it the match is page-wide, and a word that appears both in a background tab label
|
|
18
|
+
* and in the dialog that just opened satisfies the predicate BEFORE the action runs, so
|
|
19
|
+
* `act_and_wait` reports `already_true` for an action that did exactly the right thing.
|
|
20
|
+
*/
|
|
21
|
+
scope?: string;
|
|
22
|
+
} | {
|
|
23
|
+
kind: typeof PredicateKind.NET;
|
|
24
|
+
method?: string;
|
|
25
|
+
urlContains?: string;
|
|
26
|
+
status?: number;
|
|
27
|
+
/** Did the call succeed? The honest field for IPC, which has no status code. */
|
|
28
|
+
ok?: boolean;
|
|
29
|
+
since?: number;
|
|
30
|
+
count?: number;
|
|
31
|
+
/** A substring the RESPONSE body must contain — what the server answered, not what was sent. */
|
|
32
|
+
bodyContains?: string;
|
|
33
|
+
} | {
|
|
34
|
+
kind: typeof PredicateKind.ROUTE;
|
|
35
|
+
pathname?: string;
|
|
36
|
+
contains?: string;
|
|
37
|
+
since?: number;
|
|
38
|
+
} | {
|
|
39
|
+
kind: typeof PredicateKind.CONSOLE;
|
|
40
|
+
level?: string;
|
|
41
|
+
absent?: boolean;
|
|
42
|
+
since?: number;
|
|
43
|
+
} | {
|
|
44
|
+
kind: typeof PredicateKind.ANIMATION;
|
|
45
|
+
name?: string;
|
|
46
|
+
target?: string;
|
|
47
|
+
completed?: boolean;
|
|
48
|
+
since?: number;
|
|
49
|
+
} | {
|
|
50
|
+
kind: typeof PredicateKind.SIGNAL;
|
|
51
|
+
name?: string;
|
|
52
|
+
dataMatches?: Record<string, unknown>;
|
|
53
|
+
since?: number;
|
|
54
|
+
} | {
|
|
55
|
+
kind: typeof PredicateKind.STATE;
|
|
56
|
+
store?: string;
|
|
57
|
+
path: string;
|
|
58
|
+
equals?: unknown;
|
|
59
|
+
} | {
|
|
60
|
+
kind: typeof PredicateKind.SETTLED;
|
|
61
|
+
quietMs?: number;
|
|
62
|
+
} | {
|
|
63
|
+
kind: typeof PredicateKind.ALL_OF;
|
|
64
|
+
predicates: Predicate[];
|
|
65
|
+
} | {
|
|
66
|
+
kind: typeof PredicateKind.ANY_OF;
|
|
67
|
+
predicates: Predicate[];
|
|
68
|
+
} | {
|
|
69
|
+
kind: typeof PredicateKind.NOT;
|
|
70
|
+
predicate: Predicate;
|
|
71
|
+
};
|
|
72
|
+
export interface ResidualQueryChecks {
|
|
73
|
+
/** Dropped fields this side CAN check, as [field, wanted value] pairs. */
|
|
74
|
+
checks: [string, string][];
|
|
75
|
+
/** Dropped fields with no descriptor to check them against — refuse rather than ignore. */
|
|
76
|
+
unusable: string[];
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Split a query's dropped fields into the ones the server can still enforce and the ones it cannot.
|
|
80
|
+
*
|
|
81
|
+
* The alternative — refuse every dropped field — breaks calls that work today and that our own
|
|
82
|
+
* cheatsheet advertises (`{ role: "button", text: "Save" }`), and breaks them into no verdict at all.
|
|
83
|
+
* Enforcing what we can and refusing only the rest keeps those calls working AS WRITTEN, which is the
|
|
84
|
+
* outcome the caller was already assuming.
|
|
85
|
+
*/
|
|
86
|
+
export declare function residualQueryChecks(query: ElementQuery): ResidualQueryChecks;
|
|
87
|
+
/** Does this element satisfy every field the locator dropped? */
|
|
88
|
+
export declare function satisfiesResiduals(element: ElementDescriptor, checks: readonly [string, string][]): boolean;
|
|
89
|
+
/** How the element's own reading of a dropped field should be REPORTED back on a failure. */
|
|
90
|
+
export declare function describeResidual(element: ElementDescriptor, field: string): string;
|
|
91
|
+
export declare const PredicateSchema: z.ZodType<Predicate>;
|
|
92
|
+
/**
|
|
93
|
+
* The fields a given predicate kind accepts, read off the schema itself.
|
|
94
|
+
*
|
|
95
|
+
* Derived rather than listed so the two can never disagree: a rejection message that names a stale
|
|
96
|
+
* field set is worse than one that names none, because the agent trusts it and retries into the same
|
|
97
|
+
* wall. Empty for a kind that is not in the union.
|
|
98
|
+
*/
|
|
99
|
+
export declare function predicateFieldsFor(kind: string): readonly string[];
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { ElementQuerySchema, ElementState, PredicateKind, QueryBy, } from '@reticlehq/core';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
/**
|
|
4
|
+
* Spellings an agent plausibly reaches for, mapped to the real field.
|
|
5
|
+
*
|
|
6
|
+
* `route` spells its field `pathname` while `state`, in the same union, spells its `path`. Every one
|
|
7
|
+
* of these was silently DROPPED before, and because these kinds have all-optional fields, dropping
|
|
8
|
+
* the only key supplied left a predicate that asserts nothing and passes on anything.
|
|
9
|
+
*/
|
|
10
|
+
const PREDICATE_ALIASES = {
|
|
11
|
+
// `text`/`value` on a `text` predicate: the kind is called "text", so `text:` is the first thing
|
|
12
|
+
// anyone writes for it, and `value` follows from the element query's own `value` field. Both were
|
|
13
|
+
// hard rejections, and a rejected predicate produces NO verdict at all — the drive ends with
|
|
14
|
+
// nothing rather than with a failure, which is the worst outcome of the three.
|
|
15
|
+
[PredicateKind.TEXT]: { text: 'contains', value: 'contains' },
|
|
16
|
+
// `urlContains`/`url` reported from the field: an agent that had just written
|
|
17
|
+
// `net { urlContains }` applied the same word to `route`, which spells it `contains`, and got
|
|
18
|
+
// `unrecognized_keys` with no list of what would have worked. The parallel it assumed is a fair
|
|
19
|
+
// one — route's `contains` matches the WHOLE route (path + query + fragment), so "the URL
|
|
20
|
+
// contains this" is precisely what it does — and asserting a redirect after login is the single
|
|
21
|
+
// most common thing an agent reaches for here.
|
|
22
|
+
[PredicateKind.ROUTE]: { path: 'pathname', urlContains: 'contains', url: 'contains' },
|
|
23
|
+
[PredicateKind.NET]: { url: 'urlContains' },
|
|
24
|
+
[PredicateKind.SIGNAL]: { data: 'dataMatches' },
|
|
25
|
+
// `of` on a composite: it reads naturally, several assertion libraries spell it that way, and it has
|
|
26
|
+
// no other meaning here. Observed twice in one drive on a real app, and each rejection cost a round
|
|
27
|
+
// trip AND produced no verdict — a composite is what an agent reaches for precisely when it has two
|
|
28
|
+
// things to prove at once, so failing it is expensive at the worst moment.
|
|
29
|
+
[PredicateKind.ALL_OF]: { of: 'predicates' },
|
|
30
|
+
[PredicateKind.ANY_OF]: { of: 'predicates' },
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Element-query fields an agent writes FLAT on an `element` predicate instead of nested under
|
|
34
|
+
* `query`. `reticle_query` takes exactly these at the top level, so an agent that has just located
|
|
35
|
+
* something writes the same words again when it asserts on it — and got `query: Required` plus an
|
|
36
|
+
* `unknown field` list for its trouble. Lifting them is unambiguous: they have no other meaning on
|
|
37
|
+
* this kind.
|
|
38
|
+
*/
|
|
39
|
+
const ELEMENT_QUERY_FIELDS = [
|
|
40
|
+
'by',
|
|
41
|
+
'value',
|
|
42
|
+
'role',
|
|
43
|
+
'name',
|
|
44
|
+
'text',
|
|
45
|
+
'label',
|
|
46
|
+
'placeholder',
|
|
47
|
+
'testid',
|
|
48
|
+
'alt',
|
|
49
|
+
'component',
|
|
50
|
+
];
|
|
51
|
+
/**
|
|
52
|
+
* The locator fields the browser actually CONSUMES for a given query — mirrors the precedence in
|
|
53
|
+
* `findIn` (packages/browser/src/dom/query.ts).
|
|
54
|
+
*
|
|
55
|
+
* An element query is not a conjunction. It is a first-match dispatch: `by`+`value` wins, then the
|
|
56
|
+
* component/source anchor, then `role` (which alone also consumes `name`), then the first of
|
|
57
|
+
* text/label/placeholder/testid/alt that is present. Every OTHER field the caller wrote is dropped on
|
|
58
|
+
* the floor, silently, and the match reported as if the whole query had been honoured.
|
|
59
|
+
*
|
|
60
|
+
* Duplicated here on purpose. The alternative is to send the question to the browser, and the browser
|
|
61
|
+
* cannot answer it: by the time a match comes back, the fields it ignored are indistinguishable from
|
|
62
|
+
* the fields it used.
|
|
63
|
+
*/
|
|
64
|
+
function usedQueryFields(query) {
|
|
65
|
+
const used = new Set();
|
|
66
|
+
if (query.by !== undefined && query.value !== undefined) {
|
|
67
|
+
used.add('by').add('value');
|
|
68
|
+
if (QueryBy.ROLE === query.by)
|
|
69
|
+
used.add('name');
|
|
70
|
+
if (QueryBy.COMPONENT === query.by)
|
|
71
|
+
used.add('component');
|
|
72
|
+
return used;
|
|
73
|
+
}
|
|
74
|
+
if (query.component !== undefined || query.source !== undefined)
|
|
75
|
+
return used.add('component');
|
|
76
|
+
if (query.role !== undefined)
|
|
77
|
+
return used.add('role').add('name');
|
|
78
|
+
for (const field of ['text', 'label', 'placeholder', 'testid', 'alt']) {
|
|
79
|
+
if (query[field] !== undefined)
|
|
80
|
+
return used.add(field);
|
|
81
|
+
}
|
|
82
|
+
return used;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* How a dropped field is checked back on the server, against the descriptor the match returned.
|
|
86
|
+
*
|
|
87
|
+
* `value` is the field this exists for: `{ role: "textbox", name: "GST amount", value: "274.58" }`
|
|
88
|
+
* read as a locator has no `by`, so the value half was discarded and the predicate collapsed to "a
|
|
89
|
+
* textbox named GST amount exists" — trivially true against an EMPTY field. Comparison is TRIMMED and
|
|
90
|
+
* exact: an input's value is a value, not prose, and a trailing space in either the app or the
|
|
91
|
+
* predicate is not a finding anybody wants. `""` asserts the field is empty, which describe() reports
|
|
92
|
+
* by omitting the field entirely.
|
|
93
|
+
*
|
|
94
|
+
* `role`/`name`/`value` compare against exactly what `reticle_query` REPORTS, so the words an agent
|
|
95
|
+
* copies out of a query result are the words that match here. `text` is a substring match, matching
|
|
96
|
+
* Testing Library's `exact: false`, and falls back to the name because describe() omits `text` when it
|
|
97
|
+
* equals the accessible name.
|
|
98
|
+
*/
|
|
99
|
+
const RESIDUAL_CHECKS = {
|
|
100
|
+
value: (element, want) => (element.value ?? '').trim() === want.trim(),
|
|
101
|
+
role: (element, want) => element.role === want,
|
|
102
|
+
name: (element, want) => element.name.trim() === want.trim(),
|
|
103
|
+
text: (element, want) => (element.text ?? element.name).includes(want),
|
|
104
|
+
};
|
|
105
|
+
/**
|
|
106
|
+
* Split a query's dropped fields into the ones the server can still enforce and the ones it cannot.
|
|
107
|
+
*
|
|
108
|
+
* The alternative — refuse every dropped field — breaks calls that work today and that our own
|
|
109
|
+
* cheatsheet advertises (`{ role: "button", text: "Save" }`), and breaks them into no verdict at all.
|
|
110
|
+
* Enforcing what we can and refusing only the rest keeps those calls working AS WRITTEN, which is the
|
|
111
|
+
* outcome the caller was already assuming.
|
|
112
|
+
*/
|
|
113
|
+
export function residualQueryChecks(query) {
|
|
114
|
+
const used = usedQueryFields(query);
|
|
115
|
+
const checks = [];
|
|
116
|
+
const unusable = [];
|
|
117
|
+
for (const field of ELEMENT_QUERY_FIELDS) {
|
|
118
|
+
const want = query[field];
|
|
119
|
+
if (want === undefined || used.has(field))
|
|
120
|
+
continue;
|
|
121
|
+
if ('string' === typeof want && RESIDUAL_CHECKS[field] !== undefined)
|
|
122
|
+
checks.push([field, want]);
|
|
123
|
+
else
|
|
124
|
+
unusable.push(field);
|
|
125
|
+
}
|
|
126
|
+
return { checks, unusable };
|
|
127
|
+
}
|
|
128
|
+
/** Does this element satisfy every field the locator dropped? */
|
|
129
|
+
export function satisfiesResiduals(element, checks) {
|
|
130
|
+
return checks.every(([field, want]) => true === RESIDUAL_CHECKS[field]?.(element, want));
|
|
131
|
+
}
|
|
132
|
+
/** How the element's own reading of a dropped field should be REPORTED back on a failure. */
|
|
133
|
+
export function describeResidual(element, field) {
|
|
134
|
+
const reading = 'value' === field
|
|
135
|
+
? (element.value ?? '')
|
|
136
|
+
: 'text' === field
|
|
137
|
+
? (element.text ?? element.name)
|
|
138
|
+
: 'role' === field
|
|
139
|
+
? element.role
|
|
140
|
+
: element.name;
|
|
141
|
+
return `${element.role} "${element.name}" ${field}=${JSON.stringify(reading)}`;
|
|
142
|
+
}
|
|
143
|
+
/** Rename known aliases before parse; an explicit canonical key always wins. */
|
|
144
|
+
function applyPredicateAliases(input) {
|
|
145
|
+
if (typeof input !== 'object' || null === input || Array.isArray(input))
|
|
146
|
+
return input;
|
|
147
|
+
const obj = input;
|
|
148
|
+
const kind = 'string' === typeof obj['kind'] ? obj['kind'] : '';
|
|
149
|
+
const aliases = PREDICATE_ALIASES[kind];
|
|
150
|
+
let out = obj;
|
|
151
|
+
if (aliases !== undefined) {
|
|
152
|
+
out = { ...obj };
|
|
153
|
+
for (const [from, to] of Object.entries(aliases)) {
|
|
154
|
+
if (out[from] === undefined)
|
|
155
|
+
continue;
|
|
156
|
+
if (out[to] === undefined)
|
|
157
|
+
out[to] = out[from];
|
|
158
|
+
delete out[from];
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return PredicateKind.ELEMENT === kind ? liftElementQuery(out) : out;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Fold flat query fields into `query`. An explicit `query` wins outright — a caller that supplied
|
|
165
|
+
* both told us which one it meant, and merging the two would invent a locator neither side wrote.
|
|
166
|
+
*/
|
|
167
|
+
function liftElementQuery(obj) {
|
|
168
|
+
const loose = ELEMENT_QUERY_FIELDS.filter((field) => obj[field] !== undefined);
|
|
169
|
+
if (0 === loose.length)
|
|
170
|
+
return obj;
|
|
171
|
+
const out = { ...obj };
|
|
172
|
+
const query = {};
|
|
173
|
+
for (const field of loose) {
|
|
174
|
+
query[field] = out[field];
|
|
175
|
+
delete out[field];
|
|
176
|
+
}
|
|
177
|
+
if (out['query'] === undefined)
|
|
178
|
+
out['query'] = query;
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Strict on every branch. A key that is nobody's spelling is now a schema error naming it, instead of
|
|
183
|
+
* a stripped field and a green — see predicate-strict.test.ts for the MCP session that found this.
|
|
184
|
+
*
|
|
185
|
+
* Built on demand rather than at module scope: the `allOf`/`anyOf`/`not` branches reference
|
|
186
|
+
* `PredicateSchema` itself, which does not exist yet while this module is initialising. Calling it
|
|
187
|
+
* after init is what makes the union introspectable — see `predicateFieldsFor`.
|
|
188
|
+
*/
|
|
189
|
+
function predicateUnion() {
|
|
190
|
+
return z.discriminatedUnion('kind', [
|
|
191
|
+
z
|
|
192
|
+
.object({
|
|
193
|
+
kind: z.literal(PredicateKind.ELEMENT),
|
|
194
|
+
query: ElementQuerySchema,
|
|
195
|
+
state: z.nativeEnum(ElementState).optional(),
|
|
196
|
+
absent: z.boolean().optional(),
|
|
197
|
+
})
|
|
198
|
+
.strict(),
|
|
199
|
+
z
|
|
200
|
+
.object({
|
|
201
|
+
kind: z.literal(PredicateKind.TEXT),
|
|
202
|
+
contains: z.string(),
|
|
203
|
+
visible: z.boolean().optional(),
|
|
204
|
+
absent: z.boolean().optional(),
|
|
205
|
+
scope: z.string().optional(),
|
|
206
|
+
})
|
|
207
|
+
.strict(),
|
|
208
|
+
z
|
|
209
|
+
.object({
|
|
210
|
+
kind: z.literal(PredicateKind.NET),
|
|
211
|
+
method: z.string().optional(),
|
|
212
|
+
urlContains: z.string().optional(),
|
|
213
|
+
status: z.number().optional(),
|
|
214
|
+
ok: z.boolean().optional(),
|
|
215
|
+
since: z.number().optional(),
|
|
216
|
+
count: z.number().int().nonnegative().optional(),
|
|
217
|
+
/**
|
|
218
|
+
* A substring the call's RESPONSE body must contain - what the server answered.
|
|
219
|
+
*
|
|
220
|
+
* The only channel that can catch a UI echoing its own input instead of the server's answer.
|
|
221
|
+
* Reported from a real payments UI: a refund posted `{"amount":"1187.01"}`, the server read it
|
|
222
|
+
* as paise and answered 200 with `{"refunded":11.87}`, and the page displayed the number the
|
|
223
|
+
* user had typed. Request fired, exactly once, status 200, console clean, page settled — every
|
|
224
|
+
* assertable channel green on a hundred-fold wrong refund, so the verdict was `yes`.
|
|
225
|
+
*
|
|
226
|
+
* A substring rather than a JSON path, deliberately: `"refunded":11.87` is the whole assertion
|
|
227
|
+
* for the money case, it needs no schema for the body, and it works the same on JSON, form
|
|
228
|
+
* encoding and plain text. A path-and-equals form can be added later if a real case needs one;
|
|
229
|
+
* this is the shape that turns "a blob I read" into a verdict.
|
|
230
|
+
*
|
|
231
|
+
* Requires body capture (`reticle({ captureNetworkBodies: true })`), and says so when the body
|
|
232
|
+
* was never recorded rather than reporting an ordinary mismatch.
|
|
233
|
+
*/
|
|
234
|
+
bodyContains: z.string().min(1).optional(),
|
|
235
|
+
})
|
|
236
|
+
.strict(),
|
|
237
|
+
z
|
|
238
|
+
.object({
|
|
239
|
+
kind: z.literal(PredicateKind.ROUTE),
|
|
240
|
+
pathname: z.string().optional(),
|
|
241
|
+
contains: z.string().optional(),
|
|
242
|
+
since: z.number().optional(),
|
|
243
|
+
})
|
|
244
|
+
.strict(),
|
|
245
|
+
z
|
|
246
|
+
.object({
|
|
247
|
+
kind: z.literal(PredicateKind.CONSOLE),
|
|
248
|
+
level: z.string().optional(),
|
|
249
|
+
absent: z.boolean().optional(),
|
|
250
|
+
since: z.number().optional(),
|
|
251
|
+
})
|
|
252
|
+
.strict(),
|
|
253
|
+
z
|
|
254
|
+
.object({
|
|
255
|
+
kind: z.literal(PredicateKind.ANIMATION),
|
|
256
|
+
name: z.string().optional(),
|
|
257
|
+
target: z.string().optional(),
|
|
258
|
+
completed: z.boolean().optional(),
|
|
259
|
+
since: z.number().optional(),
|
|
260
|
+
})
|
|
261
|
+
.strict(),
|
|
262
|
+
z
|
|
263
|
+
.object({
|
|
264
|
+
kind: z.literal(PredicateKind.SIGNAL),
|
|
265
|
+
name: z.string().optional(),
|
|
266
|
+
dataMatches: z.record(z.unknown()).optional(),
|
|
267
|
+
since: z.number().optional(),
|
|
268
|
+
})
|
|
269
|
+
.strict(),
|
|
270
|
+
z
|
|
271
|
+
.object({
|
|
272
|
+
kind: z.literal(PredicateKind.STATE),
|
|
273
|
+
store: z.string().optional(),
|
|
274
|
+
path: z.string(),
|
|
275
|
+
equals: z.unknown().optional(),
|
|
276
|
+
})
|
|
277
|
+
.strict(),
|
|
278
|
+
z
|
|
279
|
+
.object({
|
|
280
|
+
kind: z.literal(PredicateKind.SETTLED),
|
|
281
|
+
quietMs: z.number().positive().optional(),
|
|
282
|
+
})
|
|
283
|
+
.strict(),
|
|
284
|
+
z
|
|
285
|
+
.object({ kind: z.literal(PredicateKind.ALL_OF), predicates: z.array(PredicateSchema) })
|
|
286
|
+
.strict(),
|
|
287
|
+
z
|
|
288
|
+
.object({ kind: z.literal(PredicateKind.ANY_OF), predicates: z.array(PredicateSchema) })
|
|
289
|
+
.strict(),
|
|
290
|
+
z.object({ kind: z.literal(PredicateKind.NOT), predicate: PredicateSchema }).strict(),
|
|
291
|
+
]);
|
|
292
|
+
}
|
|
293
|
+
export const PredicateSchema = z.lazy(() => z.preprocess(applyPredicateAliases, predicateUnion()));
|
|
294
|
+
/**
|
|
295
|
+
* The fields a given predicate kind accepts, read off the schema itself.
|
|
296
|
+
*
|
|
297
|
+
* Derived rather than listed so the two can never disagree: a rejection message that names a stale
|
|
298
|
+
* field set is worse than one that names none, because the agent trusts it and retries into the same
|
|
299
|
+
* wall. Empty for a kind that is not in the union.
|
|
300
|
+
*/
|
|
301
|
+
export function predicateFieldsFor(kind) {
|
|
302
|
+
for (const option of predicateUnion().options) {
|
|
303
|
+
const literal = option.shape['kind'];
|
|
304
|
+
if (literal instanceof z.ZodLiteral && literal.value === kind) {
|
|
305
|
+
return Object.keys(option.shape).filter((field) => 'kind' !== field);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return [];
|
|
309
|
+
}
|
|
@@ -11,6 +11,14 @@ export interface PredicateSession {
|
|
|
11
11
|
onEvent(listener: (event: ReticleEvent) => void): () => void;
|
|
12
12
|
/** Milliseconds since connect — the same clock that stamps event `t` (injected, testable). */
|
|
13
13
|
elapsed(): number;
|
|
14
|
+
/**
|
|
15
|
+
* Where the app is RIGHT NOW — the session's live URL, kept current across SPA navigation.
|
|
16
|
+
*
|
|
17
|
+
* Read by the `route` predicate when the window holds no route change, which is the only way
|
|
18
|
+
* "did the session survive a reload?" can be answered at all. Optional: a fake session that never
|
|
19
|
+
* navigates simply omits it and route falls back to change-only, as before.
|
|
20
|
+
*/
|
|
21
|
+
url?: string;
|
|
14
22
|
/**
|
|
15
23
|
* Learned per-ref ambient-churn counts (real-time regions that churn with no action driving them).
|
|
16
24
|
* The settle oracle drops events on learned-ambient refs so a chat/ticker page can still go quiet.
|