Repository navigation
Expand file tree
/
Copy pathresponse-views.ts
More file actions
293 lines (271 loc) · 11.5 KB
/
Copy pathresponse-views.ts
File metadata and controls
293 lines (271 loc) · 11.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
import type { ResponseLevel } from '@agent-device/kernel/contracts';
import type { ScreenshotOverlayRef, SnapshotNode } from '@agent-device/kernel/snapshot';
import type { DaemonResponseData } from './daemon-request.ts';
/**
* Phase 4 leveled response views. A view maps a command's `default` result data
* to a leveled form. The router only calls a view when `responseLevel` is
* `digest` or `full` AND a view is registered — so `default` (and every
* unregistered command) is byte-identical to today (Maestro `.ad` recompare
* safe). Views are pure functions of the default `data`.
*/
export type ResponseView = (data: DaemonResponseData, level: ResponseLevel) => DaemonResponseData;
const DIGEST_REF_LIMIT = 12;
/**
* Token-cheap snapshot digest: the node count plus the first N actionable refs
* (hittable and not occluded) with a label, and the cheap top-level signals
* (`truncated`, `visibility`, `snapshotQuality`) plus any fallback screenshot
* retrieval handle. The full node tree — the
* dominant token sink — is dropped. `full` returns today's shape unchanged
* (nothing richer is computed yet).
*/
function snapshotView(data: DaemonResponseData, level: ResponseLevel): DaemonResponseData {
if (level !== 'digest') return data;
const nodes = Array.isArray(data.nodes) ? (data.nodes as SnapshotNode[]) : [];
const refs = nodes
.filter((node) => node.hittable === true && node.interactionBlocked !== 'covered')
.slice(0, DIGEST_REF_LIMIT)
.map((node) => ({ ref: node.ref, label: node.label ?? node.value ?? node.identifier }));
// Everything here is negotiable except the tree's own size and refs, so the optional signals are
// carried by name rather than by one spread per field (#1076 refs generation, #2682 target
// activation, and the occlusion/quality/visibility warnings a digest still has to surface).
const carriedFields = [
'visibility',
'snapshotQuality',
'targetActivation',
'warnings',
'fallbackScreenshotPath',
'artifacts',
'refsGeneration',
] as const satisfies readonly (keyof DaemonResponseData)[];
const carried = Object.fromEntries(
carriedFields
.filter((field) => data[field] !== undefined)
.map((field) => [field, data[field]] as const),
);
return {
nodeCount: nodes.length,
refs,
truncated: data.truncated,
...carried,
};
}
const DIGEST_OVERLAY_LIMIT = 12;
const SCREENSHOT_DIGEST_NUMBER_FIELDS = [
'width',
'height',
'logicalWidth',
'logicalHeight',
'pixelDensity',
] as const;
/**
* Token-cheap screenshot digest: the captured `path` (the primary result), the
* total overlay-ref count, and the first N overlay refs leveled down to
* `{ ref, label }`. The per-overlay geometry (`rect`/`overlayRect`/`center`) —
* the token sink that `--overlay-refs` emits when many nodes are annotated — is
* dropped and the list is capped. `artifacts` (the client's image-retrieval
* handle, grafted on by request finalization) is preserved when present so the
* screenshot stays fetchable. `full` returns today's shape unchanged (nothing
* richer is computed yet).
*/
function screenshotView(data: DaemonResponseData, level: ResponseLevel): DaemonResponseData {
if (level !== 'digest') return data;
const overlays = Array.isArray(data.overlayRefs)
? (data.overlayRefs as ScreenshotOverlayRef[])
: [];
const overlayRefs = overlays
.slice(0, DIGEST_OVERLAY_LIMIT)
.map((overlay) => ({ ref: overlay.ref, label: overlay.label }));
return {
...pickScreenshotDigestMetadata(data),
overlayCount: overlays.length,
overlayRefs,
...(data.warnings !== undefined ? { warnings: data.warnings } : {}),
...(data.artifacts !== undefined ? { artifacts: data.artifacts } : {}),
};
}
function pickScreenshotDigestMetadata(data: DaemonResponseData): DaemonResponseData {
const metadata: DaemonResponseData = {};
if (typeof data.path === 'string') metadata.path = data.path;
for (const field of SCREENSHOT_DIGEST_NUMBER_FIELDS) {
if (typeof data[field] === 'number') metadata[field] = data[field];
}
return metadata;
}
// The semantic attributes of a single matched node an agent reasons about,
// including the field facts whose explicit false/zero/empty is the signal (#2288:
// absent means unavailable). The verbose framing a digest drops — geometry
// (`rect`), tree indices (`index`/`parentIndex`/`depth`), and process/app
// plumbing (`pid`/`bundleId`/`appName`/`windowTitle`/`surface`/…) — is
// intentionally absent.
const SELECTOR_DIGEST_NODE_FIELDS = [
'role',
'type',
'subrole',
'label',
'value',
'contentDescription',
'identifier',
'enabled',
'selected',
'focused',
'editable',
'password',
'hintShowing',
'selectionStart',
'selectionEnd',
'hittable',
] as const;
function compactSelectorNode(node: SnapshotNode): Record<string, unknown> {
const compact: Record<string, unknown> = { ref: node.ref };
for (const field of SELECTOR_DIGEST_NODE_FIELDS) {
const value = node[field];
if (value !== undefined) compact[field] = value;
}
return compact;
}
/**
* Token-cheap digest shared by the `find` and `get` commands. The ONLY token
* sink in their results is the verbose matched `node`, which appears solely on a
* selector READ (text / attrs). The view is deliberately CONSERVATIVE: it acts
* only on a result that carries such a `node` and otherwise returns the data
* UNCHANGED — so the cheap exists/wait/click results AND the mutating
* `find fill` / `find focus` / `find type` interaction responses (which can
* carry agent-critical signals like `warning` / `message`) are never silently
* narrowed.
*
* • a text read drops the redundant `node` — the `text` IS the answer;
* • an attrs read compacts the `node` to its semantic attributes only;
*
* In both cases every OTHER (cheap) field is preserved verbatim. `default` and
* `full` return today's shape unchanged (nothing richer is computed yet).
*/
function selectorReadView(data: DaemonResponseData, level: ResponseLevel): DaemonResponseData {
if (level !== 'digest') return data;
const node = data.node;
if (!node || typeof node !== 'object') return data;
// A text read already carries the answer in `text`, so the node is redundant
// framing — drop only the node and keep every other (cheap) field.
if (typeof data.text === 'string') {
const { node: _node, ...rest } = data;
return rest;
}
// An attrs read: compact only the verbose node, keeping every other cheap field.
return { ...data, node: compactSelectorNode(node as SnapshotNode) };
}
// Token-cheap interaction digest: settle + resolution transforms compose;
// non-digest levels stay byte-identical.
function interactionDigestView(data: DaemonResponseData, level: ResponseLevel): DaemonResponseData {
if (level !== 'digest') return data;
return applySettleDigest(applyResolutionDigest(data));
}
// Like the settle digest, the verdict fields are the digest answer and the
// verbose list (`alternatives`) is the default-level payload (ADR 0012).
function applyResolutionDigest(data: DaemonResponseData): DaemonResponseData {
const resolution = data.resolution;
if (!resolution || typeof resolution !== 'object' || Array.isArray(resolution)) return data;
const record = resolution as Record<string, unknown>;
if (record.kind !== 'disambiguated') return data;
const { alternatives: _alternatives, ...rest } = record;
return { ...data, resolution: rest };
}
/**
* Token-cheap settle digest for interaction commands (#1101). CONSERVATIVE:
* only acts on a result that carries a `settle.diff` payload (the `--settle`
* opt-in) and otherwise returns the data UNCHANGED, so plain interaction
* responses stay byte-identical at every level. The digest keeps the verdict
* fields and the changed-line COUNTS (`diff.summary`) plus `refsGeneration`,
* and drops the diff line texts — the changed-count summary is the digest
* answer; the lines are the default-level payload. The unchanged-interactive
* `tail` (when present) is capped to the same DIGEST_REF_LIMIT as the other
* ref lists here.
*/
function applySettleDigest(data: DaemonResponseData): DaemonResponseData {
const settle = data.settle;
if (!settle || typeof settle !== 'object' || Array.isArray(settle)) return data;
const { diff, tail, ...rest } = settle as Record<string, unknown>;
if (!diff || typeof diff !== 'object' || Array.isArray(diff)) return data;
const diffRecord = diff as Record<string, unknown>;
const summary = diffRecord.summary;
const refs = readSettleDigestRefs(diffRecord.lines);
return {
...data,
settle: {
...rest,
...(refs.length > 0 ? { refs } : {}),
...cappedSettleDigestTail(tail),
diff: { summary },
},
};
}
function cappedSettleDigestTail(tail: unknown): Record<string, unknown> {
if (!Array.isArray(tail) || tail.length === 0) return {};
return { tail: tail.slice(0, DIGEST_REF_LIMIT) };
}
type DigestRef = { ref: string };
function readSettleDigestRefs(lines: unknown): DigestRef[] {
if (!Array.isArray(lines)) return [];
return lines.flatMap(readSettleDigestRef).slice(0, DIGEST_REF_LIMIT);
}
function readSettleDigestRef(line: unknown): DigestRef[] {
const record = readObjectRecord(line);
if (record?.kind !== 'added') return [];
return readDigestRef(record.ref);
}
function readDigestRef(ref: unknown): DigestRef[] {
return typeof ref === 'string' && ref.length > 0 ? [{ ref }] : [];
}
function readObjectRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
// Verbose per-entry network payload fields a digest drops. Each is the raw HTTP
// header/body material or the unparsed log line — the dominant token sink in a
// `network ... --include all` dump. Every actionable identity field
// (method/url/status/timestamp/durationMs/packetId/line and any additive field
// such as `metadata`) is kept, so a failed request stays diagnosable.
const NETWORK_DIGEST_DROPPED_ENTRY_FIELDS: readonly string[] = [
'headers',
'requestHeaders',
'responseHeaders',
'requestBody',
'responseBody',
'raw',
];
/**
* Token-cheap network digest (#1186). CONSERVATIVE and opt-in: it acts only at
* `digest` and otherwise returns the data UNCHANGED, so `default` (byte-identical
* wire shape) and `full` are never narrowed. It preserves the ENTIRE top-level
* dump — `path`, `exists`, `active`, `state`, `backend`, `include`,
* `scannedLines`, `matchedLines`, `limits`, `notes`, and any additive fields —
* and EVERY entry, dropping only the verbose per-entry payload material
* (headers/bodies/raw log line). Nothing is capped or reordered.
*/
function networkView(data: DaemonResponseData, level: ResponseLevel): DaemonResponseData {
if (level !== 'digest') return data;
const entries = data.entries;
if (!Array.isArray(entries)) return data;
return { ...data, entries: entries.map(compactNetworkEntry) };
}
function compactNetworkEntry(entry: unknown): unknown {
const record = readObjectRecord(entry);
if (!record) return entry;
const compact: Record<string, unknown> = {};
for (const [key, value] of Object.entries(record)) {
if (NETWORK_DIGEST_DROPPED_ENTRY_FIELDS.includes(key)) continue;
compact[key] = value;
}
return compact;
}
export const RESPONSE_VIEWS: Record<string, ResponseView> = {
snapshot: snapshotView,
screenshot: screenshotView,
find: selectorReadView,
get: selectorReadView,
press: interactionDigestView,
click: interactionDigestView,
fill: interactionDigestView,
longpress: interactionDigestView,
hover: interactionDigestView,
network: networkView,
};