@lightcone-ai/daemon 0.22.1 → 0.23.1
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/mcp-servers/official/media-tools/index.js +42 -19
- package/mcp-servers/official/page-understanding/index.js +6 -7
- package/package.json +1 -1
- package/src/_vendor/video/cdp-touch.js +184 -0
- package/src/_vendor/video/humanized-scroll.js +251 -0
- package/src/_vendor/video/recorder/atoms.js +212 -0
- package/src/_vendor/video/recorder/index.js +68 -38
- package/src/_vendor/video/recorder/plan-executor.js +191 -394
- package/src/_vendor/video/understanding/schema.js +316 -0
- package/src/drivers/codex.js +11 -2
- package/src/tools/plan-video-segments.js +152 -22
- package/src/tools/record-url-narration.js +44 -137
- package/src/_vendor/video/recorder/phase-duration.js +0 -18
- package/src/_vendor/video/recorder/plan-estimator.js +0 -43
|
@@ -1,480 +1,278 @@
|
|
|
1
|
-
|
|
1
|
+
// V6 plan executor.
|
|
2
|
+
//
|
|
3
|
+
// Drives plan.sections[].operations[] over the recorded Chromium page.
|
|
4
|
+
// Each section's operations[] is a flat sequence of atom calls (scroll_to /
|
|
5
|
+
// hold / cursor_focus). No macros, no aliases, no V5 action vocabulary.
|
|
6
|
+
//
|
|
7
|
+
// Inputs from V5 — `action`, `target_y`, `target_y_content_label`,
|
|
8
|
+
// `focus_region`, `transition_ms`, `phase.beats[]`, agent-written `dwell_ms`
|
|
9
|
+
// — are deliberately rejected with `phase_v5_fields_removed` so plans
|
|
10
|
+
// migrated incorrectly fail loudly instead of silently degrading.
|
|
11
|
+
//
|
|
12
|
+
// Safe-region check: every scroll_to.y / cursor_focus.y is validated against
|
|
13
|
+
// page_understanding.unsafe_regions before the atom executes. Out-of-range
|
|
14
|
+
// or unsafe coordinates throw — no silent skipping.
|
|
15
|
+
|
|
2
16
|
import { ATOMS, ATOM_NAMES } from './atoms.js';
|
|
3
|
-
import {
|
|
17
|
+
import { findOverlappingUnsafeRegion } from '../understanding/schema.js';
|
|
4
18
|
import { getCdpSession } from '../cdp-touch.js';
|
|
5
19
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
if (!Array.isArray(value) || value.length !== 2) return null;
|
|
19
|
-
const start = Number(value[0]);
|
|
20
|
-
const end = Number(value[1]);
|
|
21
|
-
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
|
|
22
|
-
const low = Math.round(Math.min(start, end));
|
|
23
|
-
const high = Math.round(Math.max(start, end));
|
|
24
|
-
return [low, high];
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
// Canonical action vocabulary = atoms + macros. Macros are the day-to-day
|
|
28
|
-
// names plans should use; atoms are exposed for sequencing via phase.beats[]
|
|
29
|
-
// when a macro doesn't fit. The legacy V2 names (smooth_scroll, fast_scroll,
|
|
30
|
-
// linear_scroll_during, scroll_back) are still accepted via ACTION_ALIASES
|
|
31
|
-
// in macros.js so existing plans keep running, but new plans should pick
|
|
32
|
-
// from the canonical list.
|
|
33
|
-
//
|
|
34
|
-
// "hold" is its own canonical action (not a macro) because it's the leaf
|
|
35
|
-
// case — pure pause — and the agent shouldn't have to know whether it's
|
|
36
|
-
// implemented as macro or atom.
|
|
37
|
-
export const SUPPORTED_PHASE_ACTIONS = Object.freeze([
|
|
38
|
-
// macros (recommended for most sections)
|
|
39
|
-
'scroll_to_dwell', // fast transition + dwell with micro-motion (default)
|
|
40
|
-
'narrated_pan', // continuous linear scroll (long lists)
|
|
41
|
-
'focal_arc', // cursor across N focal points (short pages)
|
|
42
|
-
// atoms (for power use — usually inside phase.beats[])
|
|
43
|
-
'scroll_to',
|
|
44
|
-
'hold',
|
|
45
|
-
'micro_oscillate',
|
|
46
|
-
'cursor_focus',
|
|
47
|
-
// legacy V2 names — kept for backward compat
|
|
48
|
-
'smooth_scroll',
|
|
49
|
-
'fast_scroll',
|
|
50
|
-
'linear_scroll_during',
|
|
51
|
-
'scroll_back',
|
|
52
|
-
]);
|
|
53
|
-
|
|
54
|
-
// Common spellings authors reach for, mapped onto the canonical action above.
|
|
55
|
-
// Note: scroll_down / scroll_up are intentionally NOT aliased — there is no blind
|
|
56
|
-
// scroll; an unrecognised action raises phase_action_unsupported so the plan gets
|
|
57
|
-
// fixed rather than silently degraded.
|
|
58
|
-
const PHASE_ACTION_ALIASES = new Map([
|
|
59
|
-
['scroll_to', 'scroll_to_dwell'],
|
|
60
|
-
['scrollto', 'scroll_to_dwell'],
|
|
61
|
-
['scroll', 'scroll_to_dwell'],
|
|
62
|
-
['scroll_to_region', 'scroll_to_dwell'],
|
|
63
|
-
['scroll_to_y', 'scroll_to_dwell'],
|
|
64
|
-
['dwell', 'scroll_to_dwell'],
|
|
65
|
-
['focus_hold', 'scroll_to_dwell'],
|
|
66
|
-
['pan', 'linear_scroll_during'],
|
|
67
|
-
['narrated_pan', 'linear_scroll_during'],
|
|
68
|
-
['linear_scroll', 'linear_scroll_during'],
|
|
69
|
-
['scroll_during', 'linear_scroll_during'],
|
|
70
|
-
['scroll_while_narrating', 'linear_scroll_during'],
|
|
71
|
-
['return', 'scroll_back'],
|
|
72
|
-
['return_anchor', 'scroll_back'],
|
|
73
|
-
['back', 'scroll_back'],
|
|
74
|
-
['scroll_to_top', 'scroll_back'],
|
|
75
|
-
['wait', 'hold'],
|
|
76
|
-
['pause', 'hold'],
|
|
77
|
-
['stay', 'hold'],
|
|
78
|
-
['focus', 'cursor_focus'],
|
|
79
|
-
['highlight', 'cursor_focus'],
|
|
20
|
+
const V5_FIELDS_ON_SECTION = Object.freeze([
|
|
21
|
+
'action',
|
|
22
|
+
'target_y',
|
|
23
|
+
'target_y_content_label',
|
|
24
|
+
'targetYContentLabel',
|
|
25
|
+
'focus_region',
|
|
26
|
+
'focusRegion',
|
|
27
|
+
'transition_ms',
|
|
28
|
+
'transition_ratio',
|
|
29
|
+
'beats',
|
|
30
|
+
'visual_action',
|
|
31
|
+
'camera_motion',
|
|
80
32
|
]);
|
|
81
33
|
|
|
82
|
-
function
|
|
83
|
-
|
|
84
|
-
if (!name) return '';
|
|
85
|
-
if (SUPPORTED_PHASE_ACTIONS.includes(name)) return name;
|
|
86
|
-
return PHASE_ACTION_ALIASES.get(name) || name;
|
|
34
|
+
function nowMs(getNowMs) {
|
|
35
|
+
return Number(getNowMs?.()) || Date.now();
|
|
87
36
|
}
|
|
88
37
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
if (va && typeof va === 'object') return va;
|
|
93
|
-
if (typeof va === 'string' && va.trim()) return { type: va.trim() };
|
|
94
|
-
return {};
|
|
38
|
+
function normalizeText(value) {
|
|
39
|
+
if (typeof value !== 'string') return '';
|
|
40
|
+
return value.trim();
|
|
95
41
|
}
|
|
96
42
|
|
|
97
|
-
function
|
|
98
|
-
|
|
99
|
-
if (motion === 'narrated_pan') return 'linear_scroll_during';
|
|
100
|
-
if (motion === 'return_anchor') return 'scroll_back';
|
|
101
|
-
if (motion === 'cursor_focus') return 'cursor_focus';
|
|
102
|
-
if (motion === 'focus_hold') return 'scroll_to_dwell';
|
|
103
|
-
return '';
|
|
43
|
+
function resolveSectionId(section, index) {
|
|
44
|
+
return normalizeText(section?.id ?? section?.phase_id ?? section?.name) || `section_${index + 1}`;
|
|
104
45
|
}
|
|
105
46
|
|
|
106
|
-
function
|
|
107
|
-
for (const
|
|
108
|
-
if (
|
|
109
|
-
|
|
110
|
-
|
|
47
|
+
function assertNoV5Fields(section, sectionId) {
|
|
48
|
+
for (const field of V5_FIELDS_ON_SECTION) {
|
|
49
|
+
if (section && Object.prototype.hasOwnProperty.call(section, field)) {
|
|
50
|
+
const error = new Error(
|
|
51
|
+
`phase_v5_fields_removed: section "${sectionId}" carries V5 field "${field}". `
|
|
52
|
+
+ 'V6 plans use only `text`, `audio_path`, `dwell_ms` (set by plan_video_segments), '
|
|
53
|
+
+ 'and `operations[]` (each: {atom, duration_ms, ...atom-specific}).',
|
|
54
|
+
);
|
|
55
|
+
error.code = 'PHASE_V5_FIELDS_REMOVED';
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
111
58
|
}
|
|
112
|
-
return null;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function normalizeSectionAsPhase(section = {}, index = 0) {
|
|
116
|
-
const phaseId = normalizeText(section.id ?? section.phase_id ?? section.name) || `phase_${index + 1}`;
|
|
117
|
-
const visualAction = visualActionObject(section);
|
|
118
|
-
const focusRegion = normalizeRange(
|
|
119
|
-
section.focus_region
|
|
120
|
-
?? section.focusRegion
|
|
121
|
-
?? visualAction.focus_region
|
|
122
|
-
?? visualAction.focusRegion
|
|
123
|
-
);
|
|
124
|
-
const explicitAction = normalizeActionName(section.action ?? visualAction.type ?? visualAction.action);
|
|
125
|
-
const inferred = explicitAction || inferActionFromCameraMotion(section);
|
|
126
|
-
const targetY = pickFirstNumber(
|
|
127
|
-
section.target_y, section.to_y, section.y, section.scroll_y,
|
|
128
|
-
visualAction.target_y, visualAction.to_y, visualAction.y, visualAction.scroll_y,
|
|
129
|
-
);
|
|
130
|
-
const hasTarget = focusRegion != null || targetY != null;
|
|
131
|
-
const action = inferred || (hasTarget ? 'scroll_to_dwell' : 'hold');
|
|
132
|
-
|
|
133
|
-
return {
|
|
134
|
-
...section,
|
|
135
|
-
id: phaseId,
|
|
136
|
-
phase_id: phaseId,
|
|
137
|
-
action,
|
|
138
|
-
focus_region: focusRegion ?? null,
|
|
139
|
-
visual_action: visualAction,
|
|
140
|
-
target_y: targetY,
|
|
141
|
-
from_y: pickFirstNumber(section.from_y, visualAction.from_y),
|
|
142
|
-
to_y: pickFirstNumber(section.to_y, visualAction.to_y, section.y, visualAction.y),
|
|
143
|
-
transition_ms: section.transition_ms ?? visualAction.transition_ms ?? visualAction.duration_ms ?? null,
|
|
144
|
-
duration_ms: section.duration_ms ?? section.dwell_ms ?? section.audio_duration_ms
|
|
145
|
-
?? (section.presentation && Number.isFinite(Number(section.presentation.duration))
|
|
146
|
-
? Math.round(Number(section.presentation.duration) * 1000)
|
|
147
|
-
: null),
|
|
148
|
-
};
|
|
149
59
|
}
|
|
150
60
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
sections.forEach((section, sectionIndex) => {
|
|
161
|
-
const nested = Array.isArray(section?.phases) ? section.phases : null;
|
|
162
|
-
if (nested && nested.length > 0) {
|
|
163
|
-
const prefix = normalizeText(section.id ?? section.phase_id ?? section.name) || `s${sectionIndex + 1}`;
|
|
164
|
-
nested.forEach((subPhase, subIndex) => {
|
|
165
|
-
const merged = {
|
|
166
|
-
...subPhase,
|
|
167
|
-
id: subPhase.id ?? subPhase.phase_id ?? subPhase.name ?? `${prefix}_${subIndex + 1}`,
|
|
168
|
-
};
|
|
169
|
-
flattened.push(normalizeSectionAsPhase(merged, flattened.length));
|
|
170
|
-
});
|
|
171
|
-
} else {
|
|
172
|
-
flattened.push(normalizeSectionAsPhase(section, flattened.length));
|
|
173
|
-
}
|
|
174
|
-
});
|
|
175
|
-
return flattened;
|
|
61
|
+
// scroll_to / cursor_focus have `y` (cursor_focus has it as part of x,y); validate against
|
|
62
|
+
// page_understanding.full_height_px + unsafe_regions before dispatch.
|
|
63
|
+
function assertYWithinBounds(y, { fullHeightPx, atomName, sectionId, operationIndex }) {
|
|
64
|
+
if (!Number.isFinite(y)) {
|
|
65
|
+
const error = new Error(
|
|
66
|
+
`operations_invalid: section "${sectionId}" operations[${operationIndex}].y is missing or not a number (atom=${atomName}).`,
|
|
67
|
+
);
|
|
68
|
+
error.code = 'OPERATIONS_INVALID';
|
|
69
|
+
throw error;
|
|
176
70
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
phase.action ?? phase.visual_action?.type ?? phase.visual_action?.action
|
|
186
|
-
);
|
|
187
|
-
if (explicit) return explicit;
|
|
188
|
-
return inferActionFromCameraMotion(phase);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
function resolvePhaseId(phase = {}, index = 0) {
|
|
192
|
-
return normalizeText(phase.id ?? phase.phase_id ?? phase.name) || `phase_${index + 1}`;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
function nowMs(getNowMs) {
|
|
196
|
-
return Number(getNowMs?.()) || Date.now();
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
function resolveTransitionMs(phase, fallback) {
|
|
200
|
-
const parsed = normalizeInteger(phase?.transition_ms, null);
|
|
201
|
-
if (parsed !== null && parsed >= 0) return parsed;
|
|
202
|
-
return fallback;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function resolveTargetY(phase, fallback = null) {
|
|
206
|
-
const explicit = pickFirstNumber(
|
|
207
|
-
phase?.target_y, phase?.to_y, phase?.y, phase?.scroll_y,
|
|
208
|
-
phase?.visual_action?.target_y, phase?.visual_action?.to_y,
|
|
209
|
-
phase?.visual_action?.y, phase?.visual_action?.scroll_y,
|
|
210
|
-
);
|
|
211
|
-
if (explicit != null) return explicit;
|
|
212
|
-
|
|
213
|
-
const focusRegion = normalizeRange(
|
|
214
|
-
phase?.focus_region
|
|
215
|
-
?? phase?.focusRegion
|
|
216
|
-
?? phase?.highlight?.y_range
|
|
217
|
-
?? phase?.semantic_focus_region
|
|
218
|
-
?? phase?.semanticFocusRegion
|
|
219
|
-
?? phase?.visual_action?.focus_region
|
|
220
|
-
?? phase?.visual_action?.focusRegion
|
|
221
|
-
);
|
|
222
|
-
if (focusRegion) {
|
|
223
|
-
return Math.round((focusRegion[0] + focusRegion[1]) / 2);
|
|
71
|
+
if (Number.isFinite(fullHeightPx) && fullHeightPx > 0) {
|
|
72
|
+
if (y < 0 || y > fullHeightPx) {
|
|
73
|
+
const error = new Error(
|
|
74
|
+
`y_out_of_range: section "${sectionId}" operations[${operationIndex}].y=${y} is outside [0, ${fullHeightPx}].`,
|
|
75
|
+
);
|
|
76
|
+
error.code = 'Y_OUT_OF_RANGE';
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
224
79
|
}
|
|
225
|
-
return fallback;
|
|
226
80
|
}
|
|
227
81
|
|
|
228
|
-
function
|
|
229
|
-
const
|
|
230
|
-
if (
|
|
82
|
+
function assertYNotInUnsafeRegion(y, { unsafeRegions, atomName, sectionId, operationIndex }) {
|
|
83
|
+
const hit = findOverlappingUnsafeRegion(unsafeRegions, y);
|
|
84
|
+
if (hit) {
|
|
231
85
|
const error = new Error(
|
|
232
|
-
`
|
|
233
|
-
+
|
|
234
|
-
+ '
|
|
86
|
+
`y_in_unsafe_region: section "${sectionId}" operations[${operationIndex}].y=${y} (atom=${atomName}) `
|
|
87
|
+
+ `falls in unsafe_regions[y=${hit.y_top}-${hit.y_bottom}, reason=${hit.reason}]. `
|
|
88
|
+
+ 'Rewrite operation to land outside this range or remove the region from page_understanding '
|
|
89
|
+
+ 'if the agent verified with the user it is safe.',
|
|
235
90
|
);
|
|
236
|
-
error.code = '
|
|
91
|
+
error.code = 'Y_IN_UNSAFE_REGION';
|
|
237
92
|
throw error;
|
|
238
93
|
}
|
|
239
|
-
return targetY;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
function resolveFromY(phase, fallback = null) {
|
|
243
|
-
const raw = phase?.from_y ?? phase?.visual_action?.from_y;
|
|
244
|
-
const parsed = Number(raw);
|
|
245
|
-
if (!Number.isFinite(parsed)) return fallback;
|
|
246
|
-
return Math.round(parsed);
|
|
247
94
|
}
|
|
248
95
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
// 1. phase.beats[] given — used as-is (agent composing arbitrary sequences).
|
|
257
|
-
// 2. phase.action given — resolved to macro (incl. legacy aliases) or
|
|
258
|
-
// single atom, phase params get passed through.
|
|
259
|
-
function phaseToBeats(phase, { fallbackFromY = 0 } = {}) {
|
|
260
|
-
if (Array.isArray(phase?.beats) && phase.beats.length > 0) {
|
|
261
|
-
return { beats: phase.beats, anchorY: null };
|
|
96
|
+
function validateOperation(op, { sectionId, operationIndex, fullHeightPx, unsafeRegions }) {
|
|
97
|
+
if (!op || typeof op !== 'object' || Array.isArray(op)) {
|
|
98
|
+
const error = new Error(
|
|
99
|
+
`operations_invalid: section "${sectionId}" operations[${operationIndex}] is not an object.`,
|
|
100
|
+
);
|
|
101
|
+
error.code = 'OPERATIONS_INVALID';
|
|
102
|
+
throw error;
|
|
262
103
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
: null;
|
|
272
|
-
return { beats: [beat], anchorY };
|
|
104
|
+
const atomName = normalizeText(op.atom);
|
|
105
|
+
if (!atomName || !ATOM_NAMES.includes(atomName)) {
|
|
106
|
+
const error = new Error(
|
|
107
|
+
`operations_invalid: section "${sectionId}" operations[${operationIndex}].atom="${atomName}" is not a known atom. `
|
|
108
|
+
+ `Allowed: ${ATOM_NAMES.join(', ')}.`,
|
|
109
|
+
);
|
|
110
|
+
error.code = 'OPERATIONS_INVALID';
|
|
111
|
+
throw error;
|
|
273
112
|
}
|
|
274
113
|
|
|
275
|
-
const
|
|
276
|
-
if (
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
114
|
+
const duration = Number(op.duration_ms);
|
|
115
|
+
if (!Number.isFinite(duration) || duration <= 0) {
|
|
116
|
+
const error = new Error(
|
|
117
|
+
`operations_invalid: section "${sectionId}" operations[${operationIndex}].duration_ms must be a positive number (got ${op.duration_ms}).`,
|
|
118
|
+
);
|
|
119
|
+
error.code = 'OPERATIONS_INVALID';
|
|
120
|
+
throw error;
|
|
281
121
|
}
|
|
282
122
|
|
|
283
|
-
if (
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
};
|
|
123
|
+
if (atomName === 'scroll_to') {
|
|
124
|
+
assertYWithinBounds(Number(op.y), { fullHeightPx, atomName, sectionId, operationIndex });
|
|
125
|
+
assertYNotInUnsafeRegion(Number(op.y), { unsafeRegions, atomName, sectionId, operationIndex });
|
|
126
|
+
} else if (atomName === 'cursor_focus') {
|
|
127
|
+
assertYWithinBounds(Number(op.y), { fullHeightPx, atomName, sectionId, operationIndex });
|
|
128
|
+
assertYNotInUnsafeRegion(Number(op.y), { unsafeRegions, atomName, sectionId, operationIndex });
|
|
129
|
+
if (!Number.isFinite(Number(op.x))) {
|
|
130
|
+
const error = new Error(
|
|
131
|
+
`operations_invalid: section "${sectionId}" operations[${operationIndex}].x is missing or not a number (atom=cursor_focus).`,
|
|
132
|
+
);
|
|
133
|
+
error.code = 'OPERATIONS_INVALID';
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
288
136
|
}
|
|
289
|
-
|
|
290
|
-
const error = new Error(
|
|
291
|
-
`phase_action_unsupported:${action || 'empty'} — supported actions: ${SUPPORTED_PHASE_ACTIONS.join(', ')}`
|
|
292
|
-
+ ' (there is no blind scroll_down/scroll_up; pick a macro or write phase.beats[] from atoms)',
|
|
293
|
-
);
|
|
294
|
-
error.code = 'PHASE_ACTION_UNSUPPORTED';
|
|
295
|
-
throw error;
|
|
137
|
+
return atomName;
|
|
296
138
|
}
|
|
297
139
|
|
|
298
|
-
|
|
299
|
-
// to the same derivations the old executors did (resolveDurationMs, etc).
|
|
300
|
-
function paramsForAtom(atomName, phase, fallbackFromY) {
|
|
301
|
-
const durationMs = resolveDurationMs(phase, null);
|
|
140
|
+
function operationToAtomParams(op, atomName, anchorY) {
|
|
302
141
|
if (atomName === 'scroll_to') {
|
|
303
142
|
return {
|
|
304
|
-
target_y:
|
|
305
|
-
duration_ms:
|
|
306
|
-
curve:
|
|
307
|
-
jitter_px: Number.isFinite(Number(
|
|
308
|
-
from_y:
|
|
143
|
+
target_y: Number(op.y),
|
|
144
|
+
duration_ms: Number(op.duration_ms),
|
|
145
|
+
curve: op.curve || 'easeInOutQuad',
|
|
146
|
+
jitter_px: Number.isFinite(Number(op.jitter_px)) ? Number(op.jitter_px) : 2,
|
|
147
|
+
from_y: anchorY,
|
|
148
|
+
mode: op.mode || 'auto',
|
|
309
149
|
};
|
|
310
150
|
}
|
|
311
151
|
if (atomName === 'hold') {
|
|
312
|
-
return { duration_ms:
|
|
313
|
-
}
|
|
314
|
-
if (atomName === 'micro_oscillate') {
|
|
315
|
-
return {
|
|
316
|
-
amplitude_px: Number(phase.amplitude_px) || 30,
|
|
317
|
-
duration_ms: durationMs ?? 0,
|
|
318
|
-
period_ms: Number(phase.period_ms) || 1400,
|
|
319
|
-
};
|
|
152
|
+
return { duration_ms: Number(op.duration_ms) };
|
|
320
153
|
}
|
|
321
154
|
if (atomName === 'cursor_focus') {
|
|
322
155
|
return {
|
|
323
|
-
x: Number(
|
|
324
|
-
y: Number(
|
|
325
|
-
duration_ms:
|
|
156
|
+
x: Number(op.x),
|
|
157
|
+
y: Number(op.y),
|
|
158
|
+
duration_ms: Number(op.duration_ms),
|
|
326
159
|
};
|
|
327
160
|
}
|
|
328
|
-
return {
|
|
161
|
+
return {};
|
|
329
162
|
}
|
|
330
163
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
const
|
|
338
|
-
|
|
164
|
+
function normalizeSection(section, index) {
|
|
165
|
+
if (!section || typeof section !== 'object' || Array.isArray(section)) {
|
|
166
|
+
const error = new Error(`plan_sections_required: sections[${index}] is not an object`);
|
|
167
|
+
error.code = 'PLAN_SECTIONS_REQUIRED';
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
const id = resolveSectionId(section, index);
|
|
171
|
+
assertNoV5Fields(section, id);
|
|
172
|
+
if (!Array.isArray(section.operations) || section.operations.length === 0) {
|
|
173
|
+
const error = new Error(
|
|
174
|
+
`operations_invalid: section "${id}" missing operations[] array. `
|
|
175
|
+
+ 'Every section must specify a non-empty operations sequence (e.g. [{atom:"scroll_to",...},{atom:"hold",...}]).',
|
|
176
|
+
);
|
|
177
|
+
error.code = 'OPERATIONS_INVALID';
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
return { ...section, id, phase_id: id };
|
|
339
181
|
}
|
|
340
182
|
|
|
341
|
-
function
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
amplitude_px: phase.amplitude_px,
|
|
353
|
-
oscillate_period_ms: phase.oscillate_period_ms,
|
|
354
|
-
};
|
|
355
|
-
if (macroName === 'focal_arc') {
|
|
356
|
-
return { ...base, points: Array.isArray(phase.points) ? phase.points : [] };
|
|
357
|
-
}
|
|
358
|
-
if (macroName === 'scroll_to_dwell') {
|
|
359
|
-
// The legacy fast_scroll alias maps to scroll_to_dwell, but with a
|
|
360
|
-
// shorter default transition_ratio so the snap feels closer to the
|
|
361
|
-
// original fast_scroll behavior.
|
|
362
|
-
const isFastScrollAlias = String(phase.action || '').toLowerCase() === 'fast_scroll';
|
|
363
|
-
if (isFastScrollAlias && base.transition_ratio === undefined) {
|
|
364
|
-
base.transition_ratio = 0.1;
|
|
365
|
-
}
|
|
183
|
+
export function normalizePlanSections(plan = {}) {
|
|
184
|
+
const sections = Array.isArray(plan?.sections) ? plan.sections
|
|
185
|
+
: Array.isArray(plan?.phases) ? plan.phases
|
|
186
|
+
: null;
|
|
187
|
+
if (!sections || sections.length === 0) {
|
|
188
|
+
const error = new Error(
|
|
189
|
+
'plan_sections_required: plan.sections must be a non-empty array. '
|
|
190
|
+
+ 'Each section is { id?, text?, audio_path?, dwell_ms?, operations: [{atom, duration_ms, ...}] }.',
|
|
191
|
+
);
|
|
192
|
+
error.code = 'PLAN_SECTIONS_REQUIRED';
|
|
193
|
+
throw error;
|
|
366
194
|
}
|
|
367
|
-
return
|
|
195
|
+
return sections.map((section, index) => normalizeSection(section, index));
|
|
368
196
|
}
|
|
369
197
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
let anchorY =
|
|
377
|
-
for (
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
const expanded = macroFn(beat.params ?? {}, { fromY: anchorY });
|
|
387
|
-
const sub = await executeBeats(page, ctx, expanded.beats, { fallbackFromY: anchorY });
|
|
388
|
-
if (sub?.anchorY != null) anchorY = sub.anchorY;
|
|
389
|
-
else if (expanded.anchorY != null) anchorY = expanded.anchorY;
|
|
390
|
-
continue;
|
|
391
|
-
}
|
|
392
|
-
const atomName = beat.atom;
|
|
198
|
+
async function runOperations(page, ctx, operations, {
|
|
199
|
+
fullHeightPx,
|
|
200
|
+
unsafeRegions,
|
|
201
|
+
sectionId,
|
|
202
|
+
fallbackAnchorY,
|
|
203
|
+
}) {
|
|
204
|
+
let anchorY = fallbackAnchorY;
|
|
205
|
+
for (let i = 0; i < operations.length; i += 1) {
|
|
206
|
+
const op = operations[i];
|
|
207
|
+
const atomName = validateOperation(op, {
|
|
208
|
+
sectionId,
|
|
209
|
+
operationIndex: i,
|
|
210
|
+
fullHeightPx,
|
|
211
|
+
unsafeRegions,
|
|
212
|
+
});
|
|
213
|
+
const params = operationToAtomParams(op, atomName, anchorY);
|
|
393
214
|
const atomFn = ATOMS[atomName];
|
|
394
|
-
if (!atomFn) {
|
|
395
|
-
const error = new Error(`beat_atom_unsupported:${atomName || 'missing'}`);
|
|
396
|
-
error.code = 'BEAT_ATOM_UNSUPPORTED';
|
|
397
|
-
throw error;
|
|
398
|
-
}
|
|
399
|
-
const params = { ...(beat.params ?? {}) };
|
|
400
|
-
// Pre-fill scroll_to's from_y from the running anchor so sequences can
|
|
401
|
-
// chain naturally without the agent recomputing positions.
|
|
402
|
-
if (atomName === 'scroll_to' && params.from_y == null) params.from_y = anchorY;
|
|
403
215
|
const result = await atomFn(page, ctx, params);
|
|
404
216
|
if (result?.anchorY != null) anchorY = result.anchorY;
|
|
405
217
|
}
|
|
406
218
|
return { anchorY };
|
|
407
219
|
}
|
|
408
220
|
|
|
409
|
-
|
|
410
|
-
lastAnchorY = null,
|
|
411
|
-
initialAnchorY = 0,
|
|
412
|
-
} = {}) {
|
|
413
|
-
const fallbackFromY = lastAnchorY ?? initialAnchorY;
|
|
414
|
-
const { beats, anchorY: macroAnchor } = phaseToBeats(phase, { fallbackFromY });
|
|
415
|
-
|
|
416
|
-
// One CDP session per phase, threaded to atoms via ctx (cached on page by
|
|
417
|
-
// cdp-touch.js so this is cheap on repeat calls). Tests pass a mock page
|
|
418
|
-
// that throws here; we swallow and let atoms that need CDP fail on demand.
|
|
419
|
-
let cdp = null;
|
|
420
|
-
try { cdp = await getCdpSession(page); } catch { /* mock pages may not expose context() */ }
|
|
421
|
-
const ctx = { cdp, fromY: fallbackFromY };
|
|
422
|
-
|
|
423
|
-
const { anchorY } = await executeBeats(page, ctx, beats, { fallbackFromY });
|
|
424
|
-
return { anchorY: anchorY ?? macroAnchor ?? null };
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
function createEvent({ tMs, action, phaseId, phaseAction, detail = {} }) {
|
|
221
|
+
function createEvent({ tMs, action, sectionId, detail = {} }) {
|
|
428
222
|
return {
|
|
429
223
|
t_ms: Math.max(0, Math.round(tMs)),
|
|
430
224
|
action,
|
|
431
|
-
phase_id:
|
|
432
|
-
|
|
225
|
+
phase_id: sectionId,
|
|
226
|
+
section_id: sectionId,
|
|
433
227
|
...detail,
|
|
434
228
|
};
|
|
435
229
|
}
|
|
436
230
|
|
|
437
231
|
export async function executePlanPhases(page, plan, {
|
|
232
|
+
pageUnderstanding = null,
|
|
438
233
|
getNowMs = () => Date.now(),
|
|
439
234
|
onEvent = null,
|
|
440
235
|
} = {}) {
|
|
441
|
-
const
|
|
236
|
+
const sections = normalizePlanSections(plan);
|
|
237
|
+
const fullHeightPx = Number(pageUnderstanding?.full_height_px) || null;
|
|
238
|
+
const unsafeRegions = Array.isArray(pageUnderstanding?.unsafe_regions)
|
|
239
|
+
? pageUnderstanding.unsafe_regions
|
|
240
|
+
: [];
|
|
241
|
+
|
|
442
242
|
const startedAt = nowMs(getNowMs);
|
|
443
243
|
const eventsLog = [];
|
|
444
244
|
let lastAnchorY = 0;
|
|
445
245
|
|
|
446
|
-
for (let index = 0; index <
|
|
447
|
-
const
|
|
448
|
-
const
|
|
449
|
-
const phaseAction = resolvePhaseAction(phase);
|
|
246
|
+
for (let index = 0; index < sections.length; index += 1) {
|
|
247
|
+
const section = sections[index];
|
|
248
|
+
const sectionId = section.id;
|
|
450
249
|
|
|
451
250
|
const startEvent = createEvent({
|
|
452
251
|
tMs: nowMs(getNowMs) - startedAt,
|
|
453
252
|
action: 'phase_start',
|
|
454
|
-
|
|
455
|
-
phaseAction,
|
|
253
|
+
sectionId,
|
|
456
254
|
});
|
|
457
255
|
eventsLog.push(startEvent);
|
|
458
256
|
if (typeof onEvent === 'function') onEvent(startEvent);
|
|
459
257
|
|
|
258
|
+
let cdp = null;
|
|
259
|
+
try { cdp = await getCdpSession(page); } catch { /* mock pages may not expose context() */ }
|
|
260
|
+
const ctx = { cdp };
|
|
261
|
+
|
|
460
262
|
try {
|
|
461
|
-
const
|
|
462
|
-
|
|
463
|
-
|
|
263
|
+
const result = await runOperations(page, ctx, section.operations, {
|
|
264
|
+
fullHeightPx,
|
|
265
|
+
unsafeRegions,
|
|
266
|
+
sectionId,
|
|
267
|
+
fallbackAnchorY: lastAnchorY,
|
|
464
268
|
});
|
|
465
|
-
|
|
466
|
-
if (Number.isFinite(anchorY)) {
|
|
467
|
-
lastAnchorY = Math.round(anchorY);
|
|
468
|
-
}
|
|
269
|
+
if (Number.isFinite(result.anchorY)) lastAnchorY = result.anchorY;
|
|
469
270
|
} catch (error) {
|
|
470
271
|
const errorEvent = createEvent({
|
|
471
272
|
tMs: nowMs(getNowMs) - startedAt,
|
|
472
273
|
action: 'phase_error',
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
detail: {
|
|
476
|
-
error: error?.message ?? 'unknown_error',
|
|
477
|
-
},
|
|
274
|
+
sectionId,
|
|
275
|
+
detail: { error: error?.message ?? 'unknown_error', code: error?.code ?? null },
|
|
478
276
|
});
|
|
479
277
|
eventsLog.push(errorEvent);
|
|
480
278
|
if (typeof onEvent === 'function') onEvent(errorEvent);
|
|
@@ -484,8 +282,7 @@ export async function executePlanPhases(page, plan, {
|
|
|
484
282
|
const endEvent = createEvent({
|
|
485
283
|
tMs: nowMs(getNowMs) - startedAt,
|
|
486
284
|
action: 'phase_end',
|
|
487
|
-
|
|
488
|
-
phaseAction,
|
|
285
|
+
sectionId,
|
|
489
286
|
});
|
|
490
287
|
eventsLog.push(endEvent);
|
|
491
288
|
if (typeof onEvent === 'function') onEvent(endEvent);
|