tapline-host 1.4.0 → 1.6.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/dist/accounts.d.ts +72 -0
- package/dist/accounts.js +208 -0
- package/dist/accounts.js.map +1 -0
- package/dist/actionlog.d.ts +20 -0
- package/dist/actionlog.js.map +1 -1
- package/dist/animations.d.ts +39 -0
- package/dist/animations.js +124 -0
- package/dist/animations.js.map +1 -0
- package/dist/api.d.ts +43 -0
- package/dist/api.js +154 -0
- package/dist/api.js.map +1 -0
- package/dist/audit.d.ts +3 -0
- package/dist/audit.js +1 -1
- package/dist/audit.js.map +1 -1
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +19 -0
- package/dist/bin.js.map +1 -0
- package/dist/brief.d.ts +41 -0
- package/dist/brief.js +78 -0
- package/dist/brief.js.map +1 -0
- package/dist/cli.d.ts +41 -1
- package/dist/cli.js +100 -53
- package/dist/cli.js.map +1 -1
- package/dist/conditions.d.ts +36 -0
- package/dist/conditions.js +67 -0
- package/dist/conditions.js.map +1 -0
- package/dist/confirm.d.ts +21 -2
- package/dist/confirm.js +39 -6
- package/dist/confirm.js.map +1 -1
- package/dist/dispatch.d.ts +27 -0
- package/dist/dispatch.js +114 -0
- package/dist/dispatch.js.map +1 -0
- package/dist/doctor.d.ts +5 -0
- package/dist/doctor.js +20 -0
- package/dist/doctor.js.map +1 -1
- package/dist/drive.d.ts +184 -0
- package/dist/drive.js +908 -0
- package/dist/drive.js.map +1 -0
- package/dist/elicit.d.ts +90 -0
- package/dist/elicit.js +179 -0
- package/dist/elicit.js.map +1 -0
- package/dist/format.d.ts +16 -0
- package/dist/format.js +81 -24
- package/dist/format.js.map +1 -1
- package/dist/images.d.ts +35 -0
- package/dist/images.js +76 -6
- package/dist/images.js.map +1 -1
- package/dist/inspect.d.ts +1 -0
- package/dist/journey.d.ts +276 -0
- package/dist/journey.js +368 -0
- package/dist/journey.js.map +1 -0
- package/dist/journeyexport.d.ts +58 -0
- package/dist/journeyexport.js +318 -0
- package/dist/journeyexport.js.map +1 -0
- package/dist/journeygif.d.ts +62 -0
- package/dist/journeygif.js +192 -0
- package/dist/journeygif.js.map +1 -0
- package/dist/journeyreport.d.ts +202 -0
- package/dist/journeyreport.js +666 -0
- package/dist/journeyreport.js.map +1 -0
- package/dist/mapcmd.d.ts +200 -0
- package/dist/mapcmd.js +412 -0
- package/dist/mapcmd.js.map +1 -0
- package/dist/mapfile.d.ts +155 -0
- package/dist/mapfile.js +575 -0
- package/dist/mapfile.js.map +1 -0
- package/dist/mcp.d.ts +14 -11
- package/dist/mcp.js +50 -318
- package/dist/mcp.js.map +1 -1
- package/dist/media.d.ts +88 -0
- package/dist/media.js +263 -0
- package/dist/media.js.map +1 -0
- package/dist/nochange.d.ts +77 -0
- package/dist/nochange.js +77 -0
- package/dist/nochange.js.map +1 -0
- package/dist/places.d.ts +239 -0
- package/dist/places.js +823 -0
- package/dist/places.js.map +1 -0
- package/dist/policy.d.ts +18 -0
- package/dist/policy.js +52 -0
- package/dist/policy.js.map +1 -1
- package/dist/protocol.d.ts +27 -1
- package/dist/protocol.js +25 -0
- package/dist/protocol.js.map +1 -1
- package/dist/reflexes.d.ts +59 -0
- package/dist/reflexes.js +133 -0
- package/dist/reflexes.js.map +1 -0
- package/dist/report.d.ts +32 -2
- package/dist/report.js +154 -9
- package/dist/report.js.map +1 -1
- package/dist/routes.d.ts +115 -0
- package/dist/routes.js +287 -0
- package/dist/routes.js.map +1 -0
- package/dist/run.d.ts +28 -1
- package/dist/run.js +32 -3
- package/dist/run.js.map +1 -1
- package/dist/runtime.d.ts +60 -0
- package/dist/runtime.js +255 -0
- package/dist/runtime.js.map +1 -0
- package/dist/script.d.ts +114 -10
- package/dist/script.js +30 -2
- package/dist/script.js.map +1 -1
- package/dist/server.d.ts +48 -2
- package/dist/server.js +88 -13
- package/dist/server.js.map +1 -1
- package/dist/thincall.d.ts +13 -0
- package/dist/thincall.js +335 -0
- package/dist/thincall.js.map +1 -0
- package/dist/tool-text-data.d.ts +10 -5
- package/dist/tool-text-data.js +11 -6
- package/dist/tool-text-data.js.map +1 -1
- package/dist/tool-text.d.ts +1 -1
- package/dist/tools.d.ts +585 -5
- package/dist/tools.js +2926 -185
- package/dist/tools.js.map +1 -1
- package/dist/uxcheck.d.ts +103 -0
- package/dist/uxcheck.js +128 -0
- package/dist/uxcheck.js.map +1 -0
- package/package.json +3 -2
- package/skills/tapline/SKILL.md +57 -11
- package/skills/tapline/references/personas.md +45 -0
- package/skills/tapline/references/setup.md +6 -1
package/dist/drive.js
ADDED
|
@@ -0,0 +1,908 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { cheapestWay, describeWay, howText, isRouteLeg } from "./mapcmd.js";
|
|
3
|
+
import { keyFromState, placeNameFromRoute, placeNow } from "./places.js";
|
|
4
|
+
import { elementSchema, placeStateSchema } from "./protocol.js";
|
|
5
|
+
import { isReflexFailure } from "./reflexes.js";
|
|
6
|
+
import { loadRoute, routeEdges, routeRunner, substituteSteps } from "./routes.js";
|
|
7
|
+
/** A saved route as one leg: a road in shape, so one loop plans and counts both. */
|
|
8
|
+
function routeRoad(edge) {
|
|
9
|
+
return { from: edge.from, to: edge.to, move: { tool: "route" }, names: { text: edge.route }, by: "tapline", seen: 0, failed: 0, ms: 0, last: edge.last, build: "" };
|
|
10
|
+
}
|
|
11
|
+
/** A jump is an `open_url` of the place's link, written as a road so one loop drives both. */
|
|
12
|
+
function jumpRoad(from, to, link) {
|
|
13
|
+
return { from: from?.name ?? "", to: to.name, move: { tool: "open_url", url: link }, by: "tapline", seen: 0, failed: 0, ms: 0, last: "", build: "" };
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* §18.4: a jump if the place has a link and `by` allows one (a user's-eyes journey never jumps); else the chain of proven
|
|
17
|
+
* roads with the fewest moves (an unproven road counts double); a tie goes to the road used most recently. Only this
|
|
18
|
+
* tool's roads, and the ones both tools have driven, are followed (§18.2).
|
|
19
|
+
*/
|
|
20
|
+
export function planWay(map, from, to, options) {
|
|
21
|
+
const link = to.link !== null && to.link !== "" ? to.link : null;
|
|
22
|
+
if (options.by !== "taps" && !options.tapsOnly && link !== null) {
|
|
23
|
+
return { by: "link", moves: [{ road: jumpRoad(from, to, link), viaLink: true }], cost: 1, proven: true };
|
|
24
|
+
}
|
|
25
|
+
if (options.by === "link")
|
|
26
|
+
return { error: "no_way" };
|
|
27
|
+
if (from === null)
|
|
28
|
+
return { error: "unknown_start" };
|
|
29
|
+
if (from.name === to.name)
|
|
30
|
+
return { by: "taps", moves: [], cost: 0, proven: true };
|
|
31
|
+
// A person has no link to follow, so a journey's roads leave the link roads, and its routes, out.
|
|
32
|
+
const roads = options.tapsOnly ? map.data().roads.filter((road) => road.move.tool !== "open_url") : map.data().roads;
|
|
33
|
+
const chain = cheapestWay(roads, from.name, to.name, (road) => map.isProven(road), options.tapsOnly ? [] : (options.routes ?? []));
|
|
34
|
+
if (chain === null)
|
|
35
|
+
return { error: "no_way" };
|
|
36
|
+
const moves = chain.map((leg) => (isRouteLeg(leg) ? { road: routeRoad(leg), viaLink: false, route: leg } : { road: leg, viaLink: false }));
|
|
37
|
+
return {
|
|
38
|
+
by: "taps",
|
|
39
|
+
moves,
|
|
40
|
+
cost: chain.reduce((sum, leg) => sum + (isRouteLeg(leg) ? leg.moves : map.isProven(leg) ? 1 : 2), 0),
|
|
41
|
+
proven: chain.every((leg) => isRouteLeg(leg) || map.isProven(leg)),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/** A refusal of the arguments, before anything is read; null when they are fine. */
|
|
45
|
+
export function driveArgsError(args) {
|
|
46
|
+
if ((args.to === undefined) === (args.route === undefined))
|
|
47
|
+
return "give exactly one of to (a place) or route (a saved route).";
|
|
48
|
+
if (args.route !== undefined && args.by !== undefined && args.by !== "any")
|
|
49
|
+
return "by goes with to; a route is driven from its start place by link or roads.";
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
// -- the app's `move` answer
|
|
53
|
+
const moveResultSchema = z.object({
|
|
54
|
+
place: placeStateSchema.nullish().catch(null),
|
|
55
|
+
next: z
|
|
56
|
+
.object({ state: z.enum(["ready", "missing", "covered", "moving"]), element: elementSchema.optional(), waitedMs: z.number().catch(0) })
|
|
57
|
+
.optional()
|
|
58
|
+
.catch(undefined),
|
|
59
|
+
});
|
|
60
|
+
/** What a `move` answer says beyond what the plain method returns: where the app is, and the next target's state. */
|
|
61
|
+
export function parseMoveResult(raw) {
|
|
62
|
+
const parsed = moveResultSchema.safeParse(raw);
|
|
63
|
+
if (!parsed.success)
|
|
64
|
+
return { place: null };
|
|
65
|
+
const { place, next } = parsed.data;
|
|
66
|
+
if (next === undefined)
|
|
67
|
+
return { place: place ?? null };
|
|
68
|
+
return { place: place ?? null, next: { state: next.state, waitedMs: next.waitedMs, ...(next.element !== undefined ? { element: next.element } : {}) } };
|
|
69
|
+
}
|
|
70
|
+
// -- words
|
|
71
|
+
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
72
|
+
const seconds = (ms) => (Math.max(0, ms) / 1000).toFixed(1);
|
|
73
|
+
function targetWords(target, names, eyes = false) {
|
|
74
|
+
// In a user's eyes only the visible text is said, never an id or a label (AGENT-TOOLS 1.13 18.12).
|
|
75
|
+
if (eyes)
|
|
76
|
+
return names?.text !== undefined ? ` ${JSON.stringify(names.text)}` : "";
|
|
77
|
+
// What a person would read comes first: the road's text or label, else its id (§18.4's example names "Language").
|
|
78
|
+
const text = names?.text ?? names?.label ?? target?.text ?? target?.label;
|
|
79
|
+
if (text !== undefined)
|
|
80
|
+
return ` ${JSON.stringify(text)}`;
|
|
81
|
+
const id = target?.id ?? names?.id;
|
|
82
|
+
return id !== undefined ? ` id=${id}` : "";
|
|
83
|
+
}
|
|
84
|
+
/** `tap "Language"`, for the step an answer names. */
|
|
85
|
+
function stepWords(road, eyes = false) {
|
|
86
|
+
const { move } = road;
|
|
87
|
+
switch (move.tool) {
|
|
88
|
+
case "tap":
|
|
89
|
+
return `tap${targetWords(move.target, road.names, eyes)}`;
|
|
90
|
+
case "long_press":
|
|
91
|
+
return `long press${targetWords(move.target, road.names, eyes)}`;
|
|
92
|
+
case "toggle":
|
|
93
|
+
return `toggle${targetWords(move.target, road.names, eyes)}`;
|
|
94
|
+
case "back":
|
|
95
|
+
return "back";
|
|
96
|
+
case "swipe":
|
|
97
|
+
return "swipe";
|
|
98
|
+
case "route":
|
|
99
|
+
return eyes ? "route" : `route ${road.names?.text ?? ""}`.trim();
|
|
100
|
+
default:
|
|
101
|
+
return "open the link";
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const said = (message) => ({ content: [{ type: "text", text: message }] });
|
|
105
|
+
const failed = (message) => ({ content: [{ type: "text", text: message }], isError: true });
|
|
106
|
+
function appendLines(answer, lines) {
|
|
107
|
+
if (lines.length === 0)
|
|
108
|
+
return answer;
|
|
109
|
+
const [top, ...rest] = answer.content;
|
|
110
|
+
if (top === undefined || top.type !== "text")
|
|
111
|
+
return answer;
|
|
112
|
+
return { ...answer, content: [{ type: "text", text: [top.text.trimEnd(), ...lines].join("\n") }, ...rest] };
|
|
113
|
+
}
|
|
114
|
+
// -- one road as a request
|
|
115
|
+
const NO_TEXT = "it has no text a person could read";
|
|
116
|
+
function wireOf(target) {
|
|
117
|
+
if (target.id !== undefined)
|
|
118
|
+
return { testID: target.id };
|
|
119
|
+
if (target.label !== undefined)
|
|
120
|
+
return { label: target.label };
|
|
121
|
+
if (target.text !== undefined)
|
|
122
|
+
return { text: target.text };
|
|
123
|
+
if (typeof target.x === "number" && typeof target.y === "number")
|
|
124
|
+
return { x: target.x, y: target.y };
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
/** Fills `[param]` pieces of a link with the values the agent gave; a missing one is named. */
|
|
128
|
+
export function fillLink(link, values) {
|
|
129
|
+
const missing = [];
|
|
130
|
+
const url = link.replace(/\[(?:\.\.\.)?([^\]]+)\]/g, (whole, name) => {
|
|
131
|
+
const value = Object.hasOwn(values, name) ? values[name] : undefined;
|
|
132
|
+
if (value === undefined || value === "") {
|
|
133
|
+
missing.push(name);
|
|
134
|
+
return whole;
|
|
135
|
+
}
|
|
136
|
+
return encodeURIComponent(value);
|
|
137
|
+
});
|
|
138
|
+
return missing.length > 0 ? { ok: false, missing } : { ok: true, url };
|
|
139
|
+
}
|
|
140
|
+
/** The request a road's move makes. A row with no id can't be replayed: the driver chooses the row (§18.2). */
|
|
141
|
+
function build(move, values, names, eyes = false) {
|
|
142
|
+
switch (move.tool) {
|
|
143
|
+
case "tap":
|
|
144
|
+
case "long_press":
|
|
145
|
+
case "toggle": {
|
|
146
|
+
const target = move.target;
|
|
147
|
+
if (target === undefined)
|
|
148
|
+
return { ok: false, why: "the road has no target" };
|
|
149
|
+
if (target.row === true)
|
|
150
|
+
return { ok: false, why: "it is a list row with no id of its own, so the driver chooses the row" };
|
|
151
|
+
// A person can't see ids: in their eyes the move is aimed by the text the road saw, and the app doesn't scroll for them (§18.12).
|
|
152
|
+
let wire;
|
|
153
|
+
if (eyes) {
|
|
154
|
+
const text = names?.text;
|
|
155
|
+
if (text === undefined || text === "")
|
|
156
|
+
return { ok: false, why: NO_TEXT };
|
|
157
|
+
wire = { text };
|
|
158
|
+
}
|
|
159
|
+
else
|
|
160
|
+
wire = wireOf(target);
|
|
161
|
+
if (wire === null)
|
|
162
|
+
return { ok: false, why: "the road has no target" };
|
|
163
|
+
const method = move.tool === "tap" ? "press" : move.tool === "long_press" ? "longPress" : "toggle";
|
|
164
|
+
return { ok: true, tool: move.tool === "tap" ? "tap" : move.tool === "long_press" ? "long_press" : "toggle", method, params: { target: wire, ...(eyes ? { noScroll: true } : {}) }, wire };
|
|
165
|
+
}
|
|
166
|
+
case "back":
|
|
167
|
+
return { ok: true, tool: "back", method: "back", params: {} };
|
|
168
|
+
case "open_url": {
|
|
169
|
+
if (move.url === undefined)
|
|
170
|
+
return { ok: false, why: "the road has no link" };
|
|
171
|
+
const filled = fillLink(move.url, values);
|
|
172
|
+
if (!filled.ok)
|
|
173
|
+
return { ok: false, why: `the link needs ${filled.missing.join(", ")}` };
|
|
174
|
+
return { ok: true, tool: "open_url", method: "openURL", params: { url: filled.url } };
|
|
175
|
+
}
|
|
176
|
+
case "swipe": {
|
|
177
|
+
const { x0, y0, x1, y1, seconds: length } = move;
|
|
178
|
+
if (x0 === undefined || y0 === undefined || x1 === undefined || y1 === undefined)
|
|
179
|
+
return { ok: false, why: "the road has no swipe points" };
|
|
180
|
+
return { ok: true, tool: "swipe", method: "swipe", params: { x0, y0, x1, y1, ...(length !== undefined ? { seconds: length } : {}) } };
|
|
181
|
+
}
|
|
182
|
+
default:
|
|
183
|
+
return { ok: false, why: `Tapline doesn't drive "${move.tool}"` };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
/** Every policy-relevant field of the element that receives the touch; an absent field stays absent (never a wildcard). */
|
|
187
|
+
export const guardOf = (element) => ({
|
|
188
|
+
id: typeof element.testID === "string" && element.testID !== "" ? element.testID : null,
|
|
189
|
+
label: typeof element.label === "string" && element.label !== "" ? element.label : null,
|
|
190
|
+
text: typeof element.text === "string" && element.text !== "" ? element.text : null,
|
|
191
|
+
role: typeof element.role === "string" && element.role !== "" ? element.role : null,
|
|
192
|
+
});
|
|
193
|
+
/** How a road names an element now: by id, else label, else text (§18.2). */
|
|
194
|
+
function moveTargetOf(element) {
|
|
195
|
+
const names = {};
|
|
196
|
+
// A list row keeps only its id: its label and text are content, never saved (§18.2).
|
|
197
|
+
if (element.row === true) {
|
|
198
|
+
if (typeof element.testID !== "string" || element.testID === "")
|
|
199
|
+
return null;
|
|
200
|
+
return { target: { id: element.testID }, names: { id: element.testID } };
|
|
201
|
+
}
|
|
202
|
+
if (typeof element.testID === "string" && element.testID !== "")
|
|
203
|
+
names.id = element.testID;
|
|
204
|
+
if (typeof element.label === "string" && element.label !== "")
|
|
205
|
+
names.label = element.label;
|
|
206
|
+
if (typeof element.text === "string" && element.text !== "")
|
|
207
|
+
names.text = element.text;
|
|
208
|
+
if (names.id !== undefined)
|
|
209
|
+
return { target: { id: names.id }, names };
|
|
210
|
+
if (names.label !== undefined)
|
|
211
|
+
return { target: { label: names.label }, names };
|
|
212
|
+
if (names.text !== undefined)
|
|
213
|
+
return { target: { text: names.text }, names };
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
// -- repair (§18.4: when the road is wrong, 1)
|
|
217
|
+
const normal = (s) => s.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
|
|
218
|
+
function editDistance(a, b) {
|
|
219
|
+
const row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
220
|
+
for (let i = 1; i <= a.length; i++) {
|
|
221
|
+
let diagonal = row[0] ?? 0;
|
|
222
|
+
row[0] = i;
|
|
223
|
+
for (let j = 1; j <= b.length; j++) {
|
|
224
|
+
const above = row[j] ?? 0;
|
|
225
|
+
row[j] = Math.min(above + 1, (row[j - 1] ?? 0) + 1, diagonal + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
226
|
+
diagonal = above;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return row[b.length] ?? 0;
|
|
230
|
+
}
|
|
231
|
+
function close(a, b) {
|
|
232
|
+
if (a === "" || b === "")
|
|
233
|
+
return false;
|
|
234
|
+
if (a === b)
|
|
235
|
+
return true;
|
|
236
|
+
if (Math.min(a.length, b.length) >= 4 && (a.includes(b) || b.includes(a)))
|
|
237
|
+
return true;
|
|
238
|
+
return Math.max(a.length, b.length) >= 5 && editDistance(a, b) <= 1;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* The element the road meant, when its own target isn't there: the same element by its other names, then the closest match.
|
|
242
|
+
* Only a single match is taken (the arrival check and the policy still decide).
|
|
243
|
+
*/
|
|
244
|
+
export function repairTarget(names, elements, find, eyes = false) {
|
|
245
|
+
const wires = [];
|
|
246
|
+
// A person sees text only: in their eyes no id or label names the element, and a list row's content is never a name (§18.12).
|
|
247
|
+
if (names.id !== undefined && !eyes)
|
|
248
|
+
wires.push({ testID: names.id });
|
|
249
|
+
if (names.label !== undefined && !eyes)
|
|
250
|
+
wires.push({ label: names.label });
|
|
251
|
+
if (names.text !== undefined)
|
|
252
|
+
wires.push({ text: names.text });
|
|
253
|
+
const usable = (element) => eyes ? element.row !== true && typeof element.text === "string" && element.text !== "" : element.row !== true || (typeof element.testID === "string" && element.testID !== "");
|
|
254
|
+
for (const wire of wires) {
|
|
255
|
+
const found = find(wire, elements).filter(usable);
|
|
256
|
+
if (found.length === 1 && found[0] !== undefined)
|
|
257
|
+
return found[0];
|
|
258
|
+
}
|
|
259
|
+
const wanted = [eyes ? undefined : names.label, names.text].filter((s) => s !== undefined).map(normal);
|
|
260
|
+
const near = elements.filter((element) => usable(element) &&
|
|
261
|
+
(element.actions?.length ?? 0) > 0 &&
|
|
262
|
+
[element.label, element.text].some((s) => typeof s === "string" && wanted.some((w) => close(normal(s), w))));
|
|
263
|
+
return near.length === 1 && near[0] !== undefined ? near[0] : null;
|
|
264
|
+
}
|
|
265
|
+
const shownName = (element) => {
|
|
266
|
+
if (element.row === true)
|
|
267
|
+
return `id=${element.testID ?? "?"}`;
|
|
268
|
+
const words = element.text ?? element.label;
|
|
269
|
+
return typeof words === "string" && words !== "" ? JSON.stringify(words) : `id=${element.testID ?? "?"}`;
|
|
270
|
+
};
|
|
271
|
+
const OFF_ROUTE_CODES = new Set(["not_found", "ambiguous", "not_actionable", "stale"]);
|
|
272
|
+
const MAX_REPLANS = 2;
|
|
273
|
+
/** Moments only a person can clear (AGENT-TOOLS 1.13 11.5): a drive is a batch, so they are answered in a batch's form and never re-run. */
|
|
274
|
+
const MOMENT_CODES = new Set(["not_connected", "in_background", "not_answering"]);
|
|
275
|
+
function batchFailure(code, message, leg, done) {
|
|
276
|
+
const text = `Ran ${done} of ${leg.n} steps; stopped at step ${leg.i + 1} (${stepWords(leg.road, leg.eyes)}): ${message} Nothing else was touched.`;
|
|
277
|
+
return { ok: false, code, message: text, answer: failed(`Error ${code}: ${text}`) };
|
|
278
|
+
}
|
|
279
|
+
/** The next target is covered: an alert goes to the person's moment, anything else is off_route (and blames no road). */
|
|
280
|
+
async function covered(svc, leg, done, now, finish) {
|
|
281
|
+
let alert = false;
|
|
282
|
+
try {
|
|
283
|
+
alert = ((await svc.tree()).alerts?.length ?? 0) > 0;
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
alert = false;
|
|
287
|
+
}
|
|
288
|
+
if (alert)
|
|
289
|
+
return batchFailure("not_actionable", "behind_alert: An alert is open; a person has to answer it.", leg, done);
|
|
290
|
+
return finish("off_route", "it's covered by something on the screen", leg, now, false);
|
|
291
|
+
}
|
|
292
|
+
function offRouteWhy(code, message) {
|
|
293
|
+
if (code === "not_found")
|
|
294
|
+
return "it isn't on this screen";
|
|
295
|
+
if (code === "ambiguous")
|
|
296
|
+
return "it matches more than one element on this screen";
|
|
297
|
+
if (code === "stale")
|
|
298
|
+
return "it is another element now than the one the policy checked";
|
|
299
|
+
const reason = message.split(":")[0] ?? "";
|
|
300
|
+
return /^[a-z_]{3,30}$/.test(reason) ? `it can't be touched now (${reason.replace(/_/g, " ")})` : "it can't be touched now";
|
|
301
|
+
}
|
|
302
|
+
/** The first line of an answer, replaced; the rest (the screen, the notes) stays. */
|
|
303
|
+
function withFirstLine(answer, line) {
|
|
304
|
+
const [top, ...rest] = answer.content;
|
|
305
|
+
if (top === undefined || top.type !== "text")
|
|
306
|
+
return answer;
|
|
307
|
+
const [, ...lines] = top.text.split("\n");
|
|
308
|
+
return { ...answer, content: [{ type: "text", text: [line, ...lines].join("\n") }, ...rest] };
|
|
309
|
+
}
|
|
310
|
+
/** Lines put in front of an answer's text. */
|
|
311
|
+
function withLinesBefore(answer, lines) {
|
|
312
|
+
const [top, ...rest] = answer.content;
|
|
313
|
+
if (top === undefined || top.type !== "text")
|
|
314
|
+
return answer;
|
|
315
|
+
return { ...answer, content: [{ type: "text", text: [...lines, top.text].join("\n") }, ...rest] };
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Takes the app to a place (AGENT-TOOLS 1.13 18.4), then runs `then` (ordinary `run` steps) when given. `rs` lets the way go
|
|
319
|
+
* through a saved route and run the `then` steps; without it a way is roads and links only.
|
|
320
|
+
*/
|
|
321
|
+
export async function driveTo(svc, args, rs) {
|
|
322
|
+
const steps = args.then;
|
|
323
|
+
// The steps are checked like `run` checks its own, before anything is touched.
|
|
324
|
+
if (steps !== undefined) {
|
|
325
|
+
const refused = rs.checkSteps(steps);
|
|
326
|
+
if (refused !== null)
|
|
327
|
+
return refused;
|
|
328
|
+
}
|
|
329
|
+
return afterThen(await driveCore(svc, args, rs, steps !== undefined || args.quiet === true), args, rs);
|
|
330
|
+
}
|
|
331
|
+
/** A drive for a caller that wants no screen on success (a replay going to a route's start). */
|
|
332
|
+
export async function driveQuietly(svc, args, rs) {
|
|
333
|
+
const core = await driveCore(svc, args, rs, true);
|
|
334
|
+
return core.ok ? { ok: true } : { ok: false, message: core.message };
|
|
335
|
+
}
|
|
336
|
+
/** `then`: the steps go on after the arrival, in the same call; the answer follows `run`'s (18.4, 9.3). */
|
|
337
|
+
async function afterThen(core, args, rs) {
|
|
338
|
+
// A dry run touches nothing, so the steps after it don't run either.
|
|
339
|
+
if (!core.ok || args.then === undefined || rs === undefined || args.dry_run === true)
|
|
340
|
+
return core.answer;
|
|
341
|
+
const ran = await rs.runSteps(args.then, true);
|
|
342
|
+
if (ran.isError === true)
|
|
343
|
+
return appendLines(ran, [...core.notes, `Note: arrived at ${core.arrived} before the steps ran.`]);
|
|
344
|
+
return withLinesBefore(ran, [core.line, ...core.notes]);
|
|
345
|
+
}
|
|
346
|
+
async function driveCore(svc, args, rs, quiet) {
|
|
347
|
+
try {
|
|
348
|
+
return await travel(svc, args, rs, quiet);
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
const text = svc.describe(error);
|
|
352
|
+
const found = /^Error (\w+): ([\s\S]*)$/.exec(text);
|
|
353
|
+
return { ok: false, code: found?.[1] ?? "internal", message: found?.[2] ?? text, answer: failed(text) };
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
/** The routes that are running now, by map folder: a route that comes back to itself would never end. */
|
|
357
|
+
const activeRoutes = new Map();
|
|
358
|
+
/** Marks a route as running; null when it already is. Call the returned function when it ends. */
|
|
359
|
+
function enterRoute(folder, name) {
|
|
360
|
+
const set = activeRoutes.get(folder) ?? new Set();
|
|
361
|
+
if (set.has(name))
|
|
362
|
+
return null;
|
|
363
|
+
set.add(name);
|
|
364
|
+
activeRoutes.set(folder, set);
|
|
365
|
+
return () => {
|
|
366
|
+
set.delete(name);
|
|
367
|
+
if (set.size === 0 && activeRoutes.get(folder) === set)
|
|
368
|
+
activeRoutes.delete(folder);
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
const repeatedRoute = (name) => `route "${name}" is already running; a route can't run itself, so this drive was refused.`;
|
|
372
|
+
async function travel(svc, args, rs, quiet) {
|
|
373
|
+
const { map } = svc;
|
|
374
|
+
const started = svc.clock();
|
|
375
|
+
const to = args.to ?? "";
|
|
376
|
+
const by = args.by ?? "any";
|
|
377
|
+
const values = args.with ?? {};
|
|
378
|
+
const eyes = svc.eyes;
|
|
379
|
+
const bad = (code, message) => ({ ok: false, code, message, answer: failed(`Error ${code}: ${message}`) });
|
|
380
|
+
// A success carries no screen when the caller adds its own (`then`, a `run` step, a replay).
|
|
381
|
+
const screenOf = (line) => (quiet ? Promise.resolve(said(line)) : svc.screen(line, args.brief));
|
|
382
|
+
if (by === "link" && svc.tapsOnly) {
|
|
383
|
+
return bad("not_allowed", "in a journey, a jump is a shortcut a user doesn't have, and this journey's allow doesn't name drive; reach the place by its roads (by: \"taps\").");
|
|
384
|
+
}
|
|
385
|
+
// The place: one the map holds, or one the app lists up front (it may have a link and no roads yet).
|
|
386
|
+
const saved = map.data().places[to];
|
|
387
|
+
const up = saved === undefined ? (await svc.known()).places.find((place) => placeNameFromRoute(place.route) === to) : undefined;
|
|
388
|
+
if (saved === undefined && up === undefined)
|
|
389
|
+
return bad("not_found", `no place named "${to}"; map lists the places.`);
|
|
390
|
+
const target = { name: to, link: saved !== undefined ? saved.link : (up?.link ?? null) };
|
|
391
|
+
// 1. Where am I? One read: it names the place, and it holds the first target for the policy.
|
|
392
|
+
let tree = await svc.tree();
|
|
393
|
+
const first = map.observe(tree);
|
|
394
|
+
let here = first.place;
|
|
395
|
+
if (here.name === to) {
|
|
396
|
+
const line = `Already at ${to}.`;
|
|
397
|
+
if (args.dry_run === true)
|
|
398
|
+
return { ok: true, answer: said(line), line, notes: [], arrived: to };
|
|
399
|
+
return { ok: true, answer: await screenOf(line), line, notes: [], arrived: to };
|
|
400
|
+
}
|
|
401
|
+
// 2. The way.
|
|
402
|
+
const options = { by, tapsOnly: svc.tapsOnly, ...(rs !== undefined ? { routes: routeEdges(map) } : {}) };
|
|
403
|
+
// A screen the map doesn't hold yet is not a start a road can leave from.
|
|
404
|
+
const startKnown = !first.isNew;
|
|
405
|
+
let plan = planWay(map, startKnown ? here : null, target, options);
|
|
406
|
+
if ("error" in plan)
|
|
407
|
+
return stopBeforeAnything(svc, plan.error, here, target, by, args);
|
|
408
|
+
const link = target.link;
|
|
409
|
+
if (plan.by === "link" && link !== null) {
|
|
410
|
+
const filled = fillLink(link, values);
|
|
411
|
+
if (!filled.ok)
|
|
412
|
+
return bad("bad_params", `${to} needs ${filled.missing.join(", ")} (give it in with).`);
|
|
413
|
+
}
|
|
414
|
+
// A saved route on the way is checked (its steps, its account) before anything is touched.
|
|
415
|
+
const prepared = new Map();
|
|
416
|
+
for (const planned of plan.moves) {
|
|
417
|
+
if (planned.route === undefined || rs === undefined)
|
|
418
|
+
continue;
|
|
419
|
+
const ready = prepareRoute(svc, rs, planned.route.route, {}, args.account, false);
|
|
420
|
+
if (!ready.ok)
|
|
421
|
+
return { ok: false, code: ready.code, message: ready.message, answer: refusal(ready) };
|
|
422
|
+
prepared.set(planned.route.route, ready.steps);
|
|
423
|
+
}
|
|
424
|
+
if (args.dry_run === true) {
|
|
425
|
+
const line = describePlan(plan, map, here, target, eyes);
|
|
426
|
+
return { ok: true, answer: said(line), line, notes: [], arrived: to };
|
|
427
|
+
}
|
|
428
|
+
// 3. Go.
|
|
429
|
+
const run = {
|
|
430
|
+
done: 0,
|
|
431
|
+
links: 0,
|
|
432
|
+
taps: 0,
|
|
433
|
+
others: 0,
|
|
434
|
+
routes: [],
|
|
435
|
+
path: [here.name],
|
|
436
|
+
repairs: [],
|
|
437
|
+
provenAgain: 0,
|
|
438
|
+
dropped: [],
|
|
439
|
+
replans: 0,
|
|
440
|
+
reflexes: 0,
|
|
441
|
+
};
|
|
442
|
+
// Each leg gives reflexes one chance before a failure is answered (1.13.1 item 10).
|
|
443
|
+
const reflexTried = new Set();
|
|
444
|
+
const allowed = new Set();
|
|
445
|
+
let fresh = tree.elements;
|
|
446
|
+
let waiting;
|
|
447
|
+
const droppedNote = (road) => (eyes ? "Note: dropped a road (it failed twice in a row)." : `Note: dropped the road ${road} (it failed twice in a row).`);
|
|
448
|
+
const finish = async (code, why, leg, now, isNew) => {
|
|
449
|
+
// A person can't see the app's place names (18.12).
|
|
450
|
+
const where = eyes ? "" : now === null ? "Now at: unknown." : `Now at: ${now.name} (${isNew ? "new" : "known"}).`;
|
|
451
|
+
const at = leg === null ? "" : `stopped at step ${leg.i + 1} of ${leg.n} (${stepWords(leg.road, eyes)}):`;
|
|
452
|
+
const message = [at, `${why}.`, where, `Done: ${plural(run.done, "move", "moves")}.`, "Nothing else was touched."].filter((part) => part !== "").join(" ");
|
|
453
|
+
const notes = run.dropped.map(droppedNote);
|
|
454
|
+
const screen = await svc.screen(`Error ${code}: ${message}`, args.brief);
|
|
455
|
+
return { ok: false, code, message, answer: appendLines({ ...screen, isError: true }, notes) };
|
|
456
|
+
};
|
|
457
|
+
for (;;) {
|
|
458
|
+
const moves = plan.moves;
|
|
459
|
+
let replanned = false;
|
|
460
|
+
for (let i = 0; i < moves.length; i++) {
|
|
461
|
+
const planned = moves[i];
|
|
462
|
+
if (planned === undefined)
|
|
463
|
+
break;
|
|
464
|
+
const { road, viaLink } = planned;
|
|
465
|
+
if (svc.aborted()) {
|
|
466
|
+
const message = `the call was cancelled. Done: ${plural(run.done, "move", "moves")}. Nothing else was touched.`;
|
|
467
|
+
return { ok: false, code: "cancelled", message, answer: failed(`Error cancelled: ${message}`) };
|
|
468
|
+
}
|
|
469
|
+
const leg = { i, n: moves.length, road, eyes };
|
|
470
|
+
const reflexed = async () => {
|
|
471
|
+
if (svc.reflexes === undefined || reflexTried.has(i))
|
|
472
|
+
return false;
|
|
473
|
+
reflexTried.add(i);
|
|
474
|
+
const fired = await svc.reflexes();
|
|
475
|
+
if (fired === 0)
|
|
476
|
+
return false;
|
|
477
|
+
run.reflexes += fired;
|
|
478
|
+
waiting = undefined;
|
|
479
|
+
fresh = null;
|
|
480
|
+
return true;
|
|
481
|
+
};
|
|
482
|
+
// A saved route is one leg: its steps run as `run` runs them, and the arrival is checked as for a road (18.5).
|
|
483
|
+
if (planned.route !== undefined) {
|
|
484
|
+
const steps = prepared.get(planned.route.route);
|
|
485
|
+
if (rs === undefined || steps === undefined)
|
|
486
|
+
return finish("off_route", "the route can't be run here", leg, here, false);
|
|
487
|
+
waiting = undefined;
|
|
488
|
+
fresh = null;
|
|
489
|
+
const leave = enterRoute(svc.map.folder(), planned.route.route);
|
|
490
|
+
if (leave === null)
|
|
491
|
+
return finish("bad_params", repeatedRoute(planned.route.route), leg, here, false);
|
|
492
|
+
const ran = await rs.runSteps(steps, false).finally(leave);
|
|
493
|
+
if (ran.isError === true) {
|
|
494
|
+
const body = ran.content.map((block) => (block.type === "text" ? block.text : "")).join("\n").trim();
|
|
495
|
+
const found = /^Error (\w+): ([\s\S]*)$/.exec(body);
|
|
496
|
+
const stop = `Stopped at step ${i + 1} of ${moves.length} (${stepWords(road, eyes)}). Done: ${plural(run.done, "move", "moves")}. Nothing else was touched.`;
|
|
497
|
+
return { ok: false, code: found?.[1] ?? "internal", message: `${found?.[2] ?? body} ${stop}`, answer: appendLines(ran, [stop]) };
|
|
498
|
+
}
|
|
499
|
+
run.done += planned.route.moves;
|
|
500
|
+
run.routes.push(planned.route.route);
|
|
501
|
+
const arrival = await arrivedAt(svc, null);
|
|
502
|
+
if (arrival === null)
|
|
503
|
+
return finish("off_route", "the app's place can't be read, so the arrival isn't checked", leg, null, false);
|
|
504
|
+
here = arrival.obs.place;
|
|
505
|
+
if (here.name !== road.to)
|
|
506
|
+
return finish("off_route", "it didn't lead where the road says", leg, here, arrival.obs.isNew);
|
|
507
|
+
run.path.push(here.name);
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
const built = build(road.move, values, road.names, eyes);
|
|
511
|
+
if (!built.ok)
|
|
512
|
+
return finish("off_route", built.why, leg, here, false);
|
|
513
|
+
// The element this move touches, as the policy sees it.
|
|
514
|
+
let element;
|
|
515
|
+
let repaired;
|
|
516
|
+
if (built.wire !== undefined && !("x" in built.wire)) {
|
|
517
|
+
const state = waiting?.state;
|
|
518
|
+
if (state === "ready" || state === "moving")
|
|
519
|
+
element = waiting?.element;
|
|
520
|
+
else if (state === "covered") {
|
|
521
|
+
if (await reflexed()) {
|
|
522
|
+
i--;
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
return covered(svc, leg, run.done, here, finish);
|
|
526
|
+
}
|
|
527
|
+
if (element === undefined) {
|
|
528
|
+
const elements = fresh ?? (tree = await svc.tree()).elements;
|
|
529
|
+
const found = svc.candidates(built.wire, elements);
|
|
530
|
+
if (found.length === 1 && found[0] !== undefined)
|
|
531
|
+
element = found[0];
|
|
532
|
+
else if (found.length > 1)
|
|
533
|
+
return finish("off_route", offRouteWhy("ambiguous", ""), leg, here, false);
|
|
534
|
+
else {
|
|
535
|
+
const meant = repairTarget({ ...road.names, ...(road.move.target?.id !== undefined ? { id: road.move.target.id } : {}) }, elements, svc.candidates, eyes);
|
|
536
|
+
if (meant === null) {
|
|
537
|
+
if (await reflexed()) {
|
|
538
|
+
i--;
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
markFailed(road, viaLink, map, run.dropped);
|
|
542
|
+
return finish("off_route", offRouteWhy("not_found", ""), leg, here, false);
|
|
543
|
+
}
|
|
544
|
+
element = meant;
|
|
545
|
+
repaired = { element: meant, was: targetWords(road.move.target).trim() };
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
waiting = undefined;
|
|
550
|
+
fresh = null;
|
|
551
|
+
// The next target, so the app waits for it as part of this move (F7).
|
|
552
|
+
const pipelined = svc.capabilities.includes("move");
|
|
553
|
+
const nextPlanned = moves[i + 1];
|
|
554
|
+
const nextRoad = nextPlanned?.road;
|
|
555
|
+
const nextBuilt = nextRoad === undefined || nextPlanned?.viaLink === true || nextPlanned?.route !== undefined ? undefined : build(nextRoad.move, values, nextRoad.names, eyes);
|
|
556
|
+
const nextWire = nextBuilt?.ok === true && nextBuilt.wire !== undefined && !("x" in nextBuilt.wire) ? nextBuilt.wire : undefined;
|
|
557
|
+
const capMs = nextRoad === undefined ? 0 : Math.min(10_000, Math.max(2000, nextRoad.ms * 3));
|
|
558
|
+
// The next target is dry-run with its own action: a long press or a toggle isn't a press.
|
|
559
|
+
const nextKind = nextBuilt?.ok === true && (nextBuilt.method === "longPress" || nextBuilt.method === "toggle") ? nextBuilt.method : "press";
|
|
560
|
+
const touched = element !== undefined && repaired !== undefined ? moveTargetOf(element) : null;
|
|
561
|
+
// A repaired target in a user's eyes is aimed by its text too, and the app doesn't scroll for the person.
|
|
562
|
+
const params = touched !== null
|
|
563
|
+
? eyes && typeof element?.text === "string"
|
|
564
|
+
? { target: { text: element.text }, noScroll: true }
|
|
565
|
+
: { target: wireOf(touched.target) ?? {} }
|
|
566
|
+
: built.params;
|
|
567
|
+
const guard = pipelined && element !== undefined ? guardOf(element) : undefined;
|
|
568
|
+
const call = {
|
|
569
|
+
tool: built.tool,
|
|
570
|
+
method: built.method,
|
|
571
|
+
params,
|
|
572
|
+
...(pipelined && element !== undefined ? { known: [element] } : {}),
|
|
573
|
+
...(guard !== undefined ? { guard } : {}),
|
|
574
|
+
...(pipelined && nextWire !== undefined ? { next: { target: nextWire, capMs, kind: nextKind } } : {}),
|
|
575
|
+
pipelined,
|
|
576
|
+
of: "drive",
|
|
577
|
+
};
|
|
578
|
+
const movedAt = svc.clock();
|
|
579
|
+
const outcome = await svc.touch(call);
|
|
580
|
+
if (outcome.kind === "stop") {
|
|
581
|
+
const lines = [`Stopped at step ${i + 1} of ${moves.length} (${stepWords(road, eyes)}). Done: ${plural(run.done, "move", "moves")}. Nothing else was touched.`];
|
|
582
|
+
const body = outcome.answer.content.map((block) => (block.type === "text" ? block.text : "")).join("\n").trim();
|
|
583
|
+
const found = /^Error (\w+): ([\s\S]*)$/.exec(body);
|
|
584
|
+
return { ok: false, code: found?.[1] ?? "not_allowed", message: `${found?.[2] ?? body} ${lines[0] ?? ""}`.trim(), answer: appendLines(outcome.answer, lines) };
|
|
585
|
+
}
|
|
586
|
+
if (outcome.kind === "error") {
|
|
587
|
+
if (isReflexFailure(outcome.code, outcome.message) && (await reflexed())) {
|
|
588
|
+
i--;
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
// Only a target that isn't there, or an arrival elsewhere, blames a road (18.2).
|
|
592
|
+
if (outcome.code === "not_actionable" && /^behind_alert/.test(outcome.message))
|
|
593
|
+
return batchFailure(outcome.code, outcome.message, leg, run.done);
|
|
594
|
+
if (OFF_ROUTE_CODES.has(outcome.code)) {
|
|
595
|
+
if (outcome.code === "not_found")
|
|
596
|
+
markFailed(road, viaLink, map, run.dropped);
|
|
597
|
+
return finish("off_route", offRouteWhy(outcome.code, outcome.message), leg, here, false);
|
|
598
|
+
}
|
|
599
|
+
if (MOMENT_CODES.has(outcome.code))
|
|
600
|
+
return batchFailure(outcome.code, outcome.message, leg, run.done);
|
|
601
|
+
const message = `${outcome.message} Done: ${plural(run.done, "move", "moves")}.`;
|
|
602
|
+
return { ok: false, code: outcome.code, message, answer: failed(`Error ${outcome.code}: ${message}`) };
|
|
603
|
+
}
|
|
604
|
+
run.done++;
|
|
605
|
+
svc.moved?.(road.move.tool, viaLink);
|
|
606
|
+
if (outcome.via !== undefined)
|
|
607
|
+
allowed.add(outcome.via);
|
|
608
|
+
if (viaLink)
|
|
609
|
+
run.links++;
|
|
610
|
+
else if (road.move.tool === "tap")
|
|
611
|
+
run.taps++;
|
|
612
|
+
else
|
|
613
|
+
run.others++;
|
|
614
|
+
// Is it where the road leads? (A claim of arrival is never made without this.)
|
|
615
|
+
const arrival = await arrivedAt(svc, outcome.place);
|
|
616
|
+
if (arrival === null)
|
|
617
|
+
return finish("off_route", "the app's place can't be read, so the arrival isn't checked", leg, null, false);
|
|
618
|
+
if (arrival.tree !== undefined)
|
|
619
|
+
fresh = arrival.tree.elements;
|
|
620
|
+
here = arrival.obs.place;
|
|
621
|
+
if (here.name !== road.to) {
|
|
622
|
+
markFailed(road, viaLink, map, run.dropped);
|
|
623
|
+
// Nothing moved, or a jump that missed on its own say: the same road or link would only miss again.
|
|
624
|
+
if (!viaLink && here.name === road.from)
|
|
625
|
+
return finish("off_route", "the screen didn't change", leg, here, false);
|
|
626
|
+
if (arrival.obs.isNew || run.replans >= MAX_REPLANS || (viaLink && by === "link"))
|
|
627
|
+
return finish("off_route", "it didn't lead where the road says", leg, here, arrival.obs.isNew);
|
|
628
|
+
// Planned again from where it is, by roads: a link that missed would only miss again.
|
|
629
|
+
const again = planWay(map, here, target, { by: by === "link" ? "link" : "taps", tapsOnly: svc.tapsOnly, ...(rs !== undefined ? { routes: routeEdges(map) } : {}) });
|
|
630
|
+
if ("error" in again)
|
|
631
|
+
return finish("off_route", "it didn't lead where the road says", leg, here, false);
|
|
632
|
+
for (const planned2 of again.moves) {
|
|
633
|
+
if (planned2.route === undefined || rs === undefined || prepared.has(planned2.route.route))
|
|
634
|
+
continue;
|
|
635
|
+
const ready = prepareRoute(svc, rs, planned2.route.route, {}, args.account, false);
|
|
636
|
+
if (!ready.ok)
|
|
637
|
+
return finish("off_route", "it didn't lead where the road says", leg, here, false);
|
|
638
|
+
prepared.set(planned2.route.route, ready.steps);
|
|
639
|
+
}
|
|
640
|
+
run.replans++;
|
|
641
|
+
if (viaLink)
|
|
642
|
+
run.path = [here.name];
|
|
643
|
+
else
|
|
644
|
+
run.path.push(here.name);
|
|
645
|
+
plan = again;
|
|
646
|
+
// A new plan's legs each get their own reflex retry.
|
|
647
|
+
reflexTried.clear();
|
|
648
|
+
replanned = true;
|
|
649
|
+
break;
|
|
650
|
+
}
|
|
651
|
+
// Clean: the road is updated only now, after the arrival check passed (18.4).
|
|
652
|
+
let proven = road;
|
|
653
|
+
if (!viaLink && repaired !== undefined && touched !== null) {
|
|
654
|
+
proven = map.repairRoad(road, { ...road.move, target: touched.target }, touched.names, repaired.element.row !== true) ?? road;
|
|
655
|
+
run.repairs.push(eyes ? `Road repaired: ${shownName(repaired.element)}.` : `Road repaired: ${shownName(repaired.element)} (was ${repaired.was}).`);
|
|
656
|
+
}
|
|
657
|
+
if (!viaLink) {
|
|
658
|
+
if (!map.isProven(proven))
|
|
659
|
+
run.provenAgain++;
|
|
660
|
+
map.markDriven(proven, svc.clock() - movedAt);
|
|
661
|
+
}
|
|
662
|
+
if (viaLink)
|
|
663
|
+
run.path = [here.name];
|
|
664
|
+
else
|
|
665
|
+
run.path.push(here.name);
|
|
666
|
+
waiting = outcome.next;
|
|
667
|
+
if (!pipelined && nextWire !== undefined)
|
|
668
|
+
waiting = await pollNext(svc, nextWire, capMs);
|
|
669
|
+
// A place change is a moment for reflexes (§18.7): one may close what came up on arrival, before the next target is read.
|
|
670
|
+
if (svc.reflexes !== undefined && here.name !== road.from) {
|
|
671
|
+
const fired = await svc.reflexes(here.name);
|
|
672
|
+
if (fired > 0) {
|
|
673
|
+
run.reflexes += fired;
|
|
674
|
+
waiting = undefined;
|
|
675
|
+
fresh = null;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
if (replanned)
|
|
680
|
+
continue;
|
|
681
|
+
break;
|
|
682
|
+
}
|
|
683
|
+
// 4. Arrived and checked.
|
|
684
|
+
// `, 1 reflex` when one fired (§18.4).
|
|
685
|
+
const how = `${howText(run.links, run.taps, run.others, run.routes, eyes)}${run.reflexes > 0 ? `, ${plural(run.reflexes, "reflex", "reflexes")}` : ""}`;
|
|
686
|
+
const roadsDriven = run.taps + run.others + run.routes.length;
|
|
687
|
+
// A person can't see the app's place names (18.12).
|
|
688
|
+
const path = !eyes && roadsDriven > 0 && run.path.length > 1 ? ` (${run.path.join(" > ")})` : "";
|
|
689
|
+
const arrived = `${to} ${how} in ${seconds(svc.clock() - started)} s`;
|
|
690
|
+
const line = `Arrived at ${arrived}${path}.`;
|
|
691
|
+
const notes = [
|
|
692
|
+
...[...allowed].map((via) => `Note: allowed by a person (in ${via === "chat" ? "chat" : "the live view"}).`),
|
|
693
|
+
...run.repairs,
|
|
694
|
+
...(run.provenAgain > 0 ? [`Note: ${plural(run.provenAgain, "road", "roads")} proven again for build ${map.currentBuild()}.`] : []),
|
|
695
|
+
...run.dropped.map(droppedNote),
|
|
696
|
+
];
|
|
697
|
+
return { ok: true, answer: appendLines(await screenOf(line), notes), line, notes, arrived };
|
|
698
|
+
}
|
|
699
|
+
function markFailed(road, viaLink, map, dropped) {
|
|
700
|
+
if (viaLink)
|
|
701
|
+
return;
|
|
702
|
+
if (map.markFailed(road) === "dropped")
|
|
703
|
+
dropped.push(`${road.from} > ${road.to}`);
|
|
704
|
+
}
|
|
705
|
+
/** Where the app is after a move: the move's own answer when it holds a route, else a read. */
|
|
706
|
+
async function arrivedAt(svc, state) {
|
|
707
|
+
if (state !== null) {
|
|
708
|
+
const key = keyFromState(state);
|
|
709
|
+
if (key !== null)
|
|
710
|
+
return { obs: svc.map.observeKey(key) };
|
|
711
|
+
try {
|
|
712
|
+
const tree = await svc.tree();
|
|
713
|
+
return { obs: svc.map.observe(tree), tree };
|
|
714
|
+
}
|
|
715
|
+
catch {
|
|
716
|
+
return null;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
const obs = await placeNow(svc.source, svc.map);
|
|
720
|
+
return obs === null ? null : { obs };
|
|
721
|
+
}
|
|
722
|
+
/** An app without `move`: waits until the next target is on screen and steady (the same frame on two looks 40 ms apart). */
|
|
723
|
+
async function pollNext(svc, target, capMs) {
|
|
724
|
+
const began = svc.clock();
|
|
725
|
+
let previous;
|
|
726
|
+
for (;;) {
|
|
727
|
+
let found = [];
|
|
728
|
+
try {
|
|
729
|
+
found = svc.candidates(target, (await svc.tree()).elements);
|
|
730
|
+
}
|
|
731
|
+
catch {
|
|
732
|
+
found = [];
|
|
733
|
+
}
|
|
734
|
+
const [only] = found;
|
|
735
|
+
if (found.length === 1 && only !== undefined) {
|
|
736
|
+
const a = previous?.frame;
|
|
737
|
+
const b = only.frame;
|
|
738
|
+
if (previous !== undefined && a != null && b != null && a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height) {
|
|
739
|
+
return { state: "ready", element: only, waitedMs: svc.clock() - began };
|
|
740
|
+
}
|
|
741
|
+
previous = only;
|
|
742
|
+
}
|
|
743
|
+
else
|
|
744
|
+
previous = undefined;
|
|
745
|
+
if (svc.clock() - began >= capMs)
|
|
746
|
+
return previous !== undefined ? { state: "moving", element: previous, waitedMs: capMs } : { state: "missing", waitedMs: capMs };
|
|
747
|
+
await svc.sleep(40);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
// -- answers that touch nothing
|
|
751
|
+
function describePlan(plan, map, from, to, eyes) {
|
|
752
|
+
const legs = plan.moves.map((move) => ({
|
|
753
|
+
from: move.road.from,
|
|
754
|
+
to: move.road.to,
|
|
755
|
+
kind: move.viaLink ? "link" : move.route !== undefined ? "route" : move.road.move.tool === "tap" ? "tap" : "other",
|
|
756
|
+
...(move.route !== undefined ? { route: move.route.route } : {}),
|
|
757
|
+
...(move.viaLink ? { url: move.road.move.url ?? to.link ?? "" } : {}),
|
|
758
|
+
proven: move.viaLink || move.route !== undefined || map.isProven(move.road),
|
|
759
|
+
}));
|
|
760
|
+
return describeWay(to.name, from?.name ?? "?", legs, map.currentBuild(), eyes);
|
|
761
|
+
}
|
|
762
|
+
async function stopBeforeAnything(svc, error, here, target, by, args) {
|
|
763
|
+
if (error === "unknown_start") {
|
|
764
|
+
const now = here === null ? "unknown" : `${here.name} (new)`;
|
|
765
|
+
// A person can't see the app's place names (18.12).
|
|
766
|
+
const where = svc.eyes ? "" : ` Now at: ${now}.`;
|
|
767
|
+
const message = `the app's current place isn't known, so no road can be followed.${where} Nothing was touched.`;
|
|
768
|
+
const screen = await svc.screen(`Error off_route: ${message}`, args.brief);
|
|
769
|
+
return { ok: false, code: "off_route", message, answer: { ...screen, isError: true } };
|
|
770
|
+
}
|
|
771
|
+
const message = by === "link"
|
|
772
|
+
? `${target.name} has no link to jump to; add one with map link, or drive it once (the driver), and it is remembered.`
|
|
773
|
+
: svc.eyes
|
|
774
|
+
? `no known way to ${target.name}; drive it once (the driver), and it is remembered.`
|
|
775
|
+
: `no known way from ${here?.name ?? "here"} to ${target.name}; drive it once (the driver), and it is remembered.`;
|
|
776
|
+
return { ok: false, code: "not_found", message, answer: failed(`Error not_found: ${message}`) };
|
|
777
|
+
}
|
|
778
|
+
const ACCOUNT_FIELD = /\$\{account\.([a-z]+)\}/g;
|
|
779
|
+
/** The account fields a route's steps type, in order, once each. */
|
|
780
|
+
export function accountFieldsUsed(steps) {
|
|
781
|
+
const fields = new Set();
|
|
782
|
+
for (const step of steps) {
|
|
783
|
+
const typed = step.text;
|
|
784
|
+
if (typeof typed !== "string")
|
|
785
|
+
continue;
|
|
786
|
+
for (const found of typed.matchAll(ACCOUNT_FIELD))
|
|
787
|
+
if (found[1] !== undefined)
|
|
788
|
+
fields.add(found[1]);
|
|
789
|
+
}
|
|
790
|
+
return [...fields];
|
|
791
|
+
}
|
|
792
|
+
/** A refused route as the call's answer: `run`'s own refusal when it gave one, else the error with its code. */
|
|
793
|
+
const refusal = (ready) => ready.answer ?? failed(`Error ${ready.code}: ${ready.message}`);
|
|
794
|
+
/**
|
|
795
|
+
* A route's steps with the variables and the account filled in, checked before anything is touched. `strictAccount` is for a
|
|
796
|
+
* route driven by name: an account it never types is a mistake. A route that is one leg of a way takes the call's account only if it types one.
|
|
797
|
+
*/
|
|
798
|
+
function prepareRoute(svc, rs, name, values, account, strictAccount) {
|
|
799
|
+
const route = loadRoute(svc.map.folder(), name);
|
|
800
|
+
if (route === null)
|
|
801
|
+
return { ok: false, code: "not_found", message: `no route named "${name}"; map lists the routes.` };
|
|
802
|
+
const used = accountFieldsUsed(route.steps);
|
|
803
|
+
if (strictAccount && used.length === 0 && account !== undefined) {
|
|
804
|
+
return { ok: false, code: "bad_params", message: `account "${account}" is named but the route ${route.name} types no \${account.username} or \${account.password}.` };
|
|
805
|
+
}
|
|
806
|
+
for (const field of used) {
|
|
807
|
+
const refused = rs.probeAccount(field, account);
|
|
808
|
+
if (refused !== null)
|
|
809
|
+
return { ok: false, code: "bad_params", message: refused };
|
|
810
|
+
}
|
|
811
|
+
const filled = substituteSteps(route.steps, values, rs.accounts, route.variables ?? []);
|
|
812
|
+
if (!filled.ok)
|
|
813
|
+
return { ok: false, code: filled.code, message: filled.message };
|
|
814
|
+
// `run`'s own checks of the steps, made now: a step a journey's eyes refuse is found before the app is driven anywhere.
|
|
815
|
+
const refused = rs.checkSteps(filled.steps);
|
|
816
|
+
if (refused !== null)
|
|
817
|
+
return { ok: false, code: "refused", message: "", answer: refused };
|
|
818
|
+
return { ok: true, route, steps: filled.steps };
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* `drive {route}`: takes the app to the route's start place when it is elsewhere, then runs its steps with the variables
|
|
822
|
+
* and the account filled in, then checks its end (18.4, 18.5). The values and the account are checked before anything is touched.
|
|
823
|
+
* The answer is the drive answer: `Arrived at home by route sign_in in 3.2 s.` and the screen.
|
|
824
|
+
*/
|
|
825
|
+
export async function driveRoute(svc, rs, args) {
|
|
826
|
+
const started = svc.clock();
|
|
827
|
+
const name = args.route ?? "";
|
|
828
|
+
const prepared = prepareRoute(svc, rs, name, args.with ?? {}, args.account, true);
|
|
829
|
+
if (!prepared.ok)
|
|
830
|
+
return refusal(prepared);
|
|
831
|
+
const { route } = prepared;
|
|
832
|
+
const then = args.then;
|
|
833
|
+
if (then !== undefined) {
|
|
834
|
+
const refused = rs.checkSteps(then);
|
|
835
|
+
if (refused !== null)
|
|
836
|
+
return refused;
|
|
837
|
+
}
|
|
838
|
+
if (args.dry_run === true) {
|
|
839
|
+
const from = route.start !== undefined ? ` from ${route.start}` : "";
|
|
840
|
+
return said(`Would run route ${route.name} (${plural(route.steps.length, "step", "steps")})${from}; nothing is touched.`);
|
|
841
|
+
}
|
|
842
|
+
// No screen is built where another answer follows: the `then` steps, a `run` step's own line.
|
|
843
|
+
const quiet = then !== undefined || args.quiet === true;
|
|
844
|
+
let startNotes = [];
|
|
845
|
+
let startFailure;
|
|
846
|
+
const ctx = {
|
|
847
|
+
here: async () => {
|
|
848
|
+
const obs = await placeNow(svc.source, svc.map);
|
|
849
|
+
return obs === null ? null : obs.place.name;
|
|
850
|
+
},
|
|
851
|
+
driveTo: async (place) => {
|
|
852
|
+
const result = await driveCore(svc, { to: place, by: "any", with: args.with, brief: args.brief, account: args.account }, rs, true);
|
|
853
|
+
if (result.ok) {
|
|
854
|
+
startNotes = result.notes;
|
|
855
|
+
return { ok: true };
|
|
856
|
+
}
|
|
857
|
+
// No way to the start is the same stop as a road that went wrong: off_route (18.5).
|
|
858
|
+
startFailure = result.code === "not_found" && result.message.startsWith("no known way") ? withFirstLine(result.answer, `Error off_route: ${result.message}`) : result.answer;
|
|
859
|
+
return { ok: false, message: result.message };
|
|
860
|
+
},
|
|
861
|
+
runSteps: (steps) => rs.runSteps(steps, !quiet),
|
|
862
|
+
};
|
|
863
|
+
const leave = enterRoute(svc.map.folder(), route.name);
|
|
864
|
+
if (leave === null)
|
|
865
|
+
return failed(`Error bad_params: ${repeatedRoute(route.name)}`);
|
|
866
|
+
const outcome = await routeRunner.run(route, args.with ?? {}, ctx, rs.accounts).finally(leave);
|
|
867
|
+
if (outcome.kind === "refused")
|
|
868
|
+
return startFailure ?? failed(`Error ${outcome.code}: ${outcome.message}`);
|
|
869
|
+
const ran = outcome.answer;
|
|
870
|
+
if (ran.isError === true)
|
|
871
|
+
return ran;
|
|
872
|
+
// The end is checked, never assumed.
|
|
873
|
+
const now = await placeNow(svc.source, svc.map);
|
|
874
|
+
const wanted = route.end;
|
|
875
|
+
if (wanted !== undefined) {
|
|
876
|
+
const stop = (why) => ({ ...withFirstLine(ran, `Error off_route: ${why}`), isError: true });
|
|
877
|
+
if (now === null)
|
|
878
|
+
return stop(`the app's place can't be read, so the end of the route ${route.name} isn't checked.`);
|
|
879
|
+
if (now.place.name !== wanted) {
|
|
880
|
+
return stop(svc.eyes
|
|
881
|
+
? `the route ${route.name} ran its steps but didn't end where it should.`
|
|
882
|
+
: `the route ${route.name} ran its steps but ended at ${now.place.name}, not at ${wanted} (its end).`);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
const place = wanted ?? now?.place.name;
|
|
886
|
+
const arrived = `${place !== undefined && !svc.eyes ? `${place} ` : ""}by route ${route.name} in ${seconds(svc.clock() - started)} s`;
|
|
887
|
+
const line = `Arrived ${place !== undefined && !svc.eyes ? "at " : ""}${arrived}.`;
|
|
888
|
+
const core = { ok: true, answer: appendLines(withFirstLine(ran, line), startNotes), line, notes: startNotes, arrived };
|
|
889
|
+
return afterThen(core, args, rs);
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* Whether the app, as it is now, has a way to a route's start place: null when it does (or is there already). A replay asks this
|
|
893
|
+
* before it restarts the app, so a route that can't be reached never costs a restart (AGENT-TOOLS 1.13 18.5).
|
|
894
|
+
*/
|
|
895
|
+
export function startProblem(map, here, start, tapsOnly) {
|
|
896
|
+
if (here === start)
|
|
897
|
+
return null;
|
|
898
|
+
const saved = map.data().places[here ?? ""];
|
|
899
|
+
if (here === null || saved === undefined) {
|
|
900
|
+
return { code: "unsupported", message: `the route starts at ${start}, and the app's place isn't known. Go to ${start} first, then run it again.` };
|
|
901
|
+
}
|
|
902
|
+
const goal = map.data().places[start];
|
|
903
|
+
const plan = planWay(map, { name: here, key: saved.key, link: saved.link }, { name: start, link: goal?.link ?? null }, { by: "any", tapsOnly });
|
|
904
|
+
if (!("error" in plan))
|
|
905
|
+
return null;
|
|
906
|
+
return { code: "off_route", message: `no known way from ${here} to ${start}; drive it once (the driver), and it is remembered.` };
|
|
907
|
+
}
|
|
908
|
+
//# sourceMappingURL=drive.js.map
|