@lightcone-ai/daemon 0.22.0 → 0.23.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/mcp-servers/official/media-tools/index.js +45 -22
- 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 +192 -386
- package/src/_vendor/video/understanding/schema.js +316 -0
- package/src/tools/plan-video-segments.js +152 -22
- package/src/tools/record-url-narration.js +44 -136
- package/src/upload-job-manager.js +4 -4
- package/src/_vendor/video/recorder/phase-duration.js +0 -18
- package/src/_vendor/video/recorder/plan-estimator.js +0 -43
|
@@ -1,471 +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
|
-
const base = {
|
|
337
|
-
target_y: targetY,
|
|
338
|
-
duration_ms: durationMs,
|
|
339
|
-
from_y: fromY,
|
|
340
|
-
transition_ms: Number.isFinite(Number(phase.transition_ms)) ? Number(phase.transition_ms) : undefined,
|
|
341
|
-
transition_ratio: Number.isFinite(Number(phase.transition_ratio)) ? Number(phase.transition_ratio) : undefined,
|
|
342
|
-
transition_curve: phase.transition_curve,
|
|
343
|
-
amplitude_px: phase.amplitude_px,
|
|
344
|
-
oscillate_period_ms: phase.oscillate_period_ms,
|
|
345
|
-
};
|
|
346
|
-
if (macroName === 'focal_arc') {
|
|
347
|
-
return { ...base, points: Array.isArray(phase.points) ? phase.points : [] };
|
|
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;
|
|
348
169
|
}
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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;
|
|
357
179
|
}
|
|
358
|
-
return
|
|
180
|
+
return { ...section, id, phase_id: id };
|
|
359
181
|
}
|
|
360
182
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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;
|
|
194
|
+
}
|
|
195
|
+
return sections.map((section, index) => normalizeSection(section, index));
|
|
196
|
+
}
|
|
197
|
+
|
|
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);
|
|
384
214
|
const atomFn = ATOMS[atomName];
|
|
385
|
-
if (!atomFn) {
|
|
386
|
-
const error = new Error(`beat_atom_unsupported:${atomName || 'missing'}`);
|
|
387
|
-
error.code = 'BEAT_ATOM_UNSUPPORTED';
|
|
388
|
-
throw error;
|
|
389
|
-
}
|
|
390
|
-
const params = { ...(beat.params ?? {}) };
|
|
391
|
-
// Pre-fill scroll_to's from_y from the running anchor so sequences can
|
|
392
|
-
// chain naturally without the agent recomputing positions.
|
|
393
|
-
if (atomName === 'scroll_to' && params.from_y == null) params.from_y = anchorY;
|
|
394
215
|
const result = await atomFn(page, ctx, params);
|
|
395
216
|
if (result?.anchorY != null) anchorY = result.anchorY;
|
|
396
217
|
}
|
|
397
218
|
return { anchorY };
|
|
398
219
|
}
|
|
399
220
|
|
|
400
|
-
|
|
401
|
-
lastAnchorY = null,
|
|
402
|
-
initialAnchorY = 0,
|
|
403
|
-
} = {}) {
|
|
404
|
-
const fallbackFromY = lastAnchorY ?? initialAnchorY;
|
|
405
|
-
const { beats, anchorY: macroAnchor } = phaseToBeats(phase, { fallbackFromY });
|
|
406
|
-
|
|
407
|
-
// One CDP session per phase, threaded to atoms via ctx (cached on page by
|
|
408
|
-
// cdp-touch.js so this is cheap on repeat calls). Tests pass a mock page
|
|
409
|
-
// that throws here; we swallow and let atoms that need CDP fail on demand.
|
|
410
|
-
let cdp = null;
|
|
411
|
-
try { cdp = await getCdpSession(page); } catch { /* mock pages may not expose context() */ }
|
|
412
|
-
const ctx = { cdp, fromY: fallbackFromY };
|
|
413
|
-
|
|
414
|
-
const { anchorY } = await executeBeats(page, ctx, beats, { fallbackFromY });
|
|
415
|
-
return { anchorY: anchorY ?? macroAnchor ?? null };
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
function createEvent({ tMs, action, phaseId, phaseAction, detail = {} }) {
|
|
221
|
+
function createEvent({ tMs, action, sectionId, detail = {} }) {
|
|
419
222
|
return {
|
|
420
223
|
t_ms: Math.max(0, Math.round(tMs)),
|
|
421
224
|
action,
|
|
422
|
-
phase_id:
|
|
423
|
-
|
|
225
|
+
phase_id: sectionId,
|
|
226
|
+
section_id: sectionId,
|
|
424
227
|
...detail,
|
|
425
228
|
};
|
|
426
229
|
}
|
|
427
230
|
|
|
428
231
|
export async function executePlanPhases(page, plan, {
|
|
232
|
+
pageUnderstanding = null,
|
|
429
233
|
getNowMs = () => Date.now(),
|
|
430
234
|
onEvent = null,
|
|
431
235
|
} = {}) {
|
|
432
|
-
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
|
+
|
|
433
242
|
const startedAt = nowMs(getNowMs);
|
|
434
243
|
const eventsLog = [];
|
|
435
244
|
let lastAnchorY = 0;
|
|
436
245
|
|
|
437
|
-
for (let index = 0; index <
|
|
438
|
-
const
|
|
439
|
-
const
|
|
440
|
-
const phaseAction = resolvePhaseAction(phase);
|
|
246
|
+
for (let index = 0; index < sections.length; index += 1) {
|
|
247
|
+
const section = sections[index];
|
|
248
|
+
const sectionId = section.id;
|
|
441
249
|
|
|
442
250
|
const startEvent = createEvent({
|
|
443
251
|
tMs: nowMs(getNowMs) - startedAt,
|
|
444
252
|
action: 'phase_start',
|
|
445
|
-
|
|
446
|
-
phaseAction,
|
|
253
|
+
sectionId,
|
|
447
254
|
});
|
|
448
255
|
eventsLog.push(startEvent);
|
|
449
256
|
if (typeof onEvent === 'function') onEvent(startEvent);
|
|
450
257
|
|
|
258
|
+
let cdp = null;
|
|
259
|
+
try { cdp = await getCdpSession(page); } catch { /* mock pages may not expose context() */ }
|
|
260
|
+
const ctx = { cdp };
|
|
261
|
+
|
|
451
262
|
try {
|
|
452
|
-
const
|
|
453
|
-
|
|
454
|
-
|
|
263
|
+
const result = await runOperations(page, ctx, section.operations, {
|
|
264
|
+
fullHeightPx,
|
|
265
|
+
unsafeRegions,
|
|
266
|
+
sectionId,
|
|
267
|
+
fallbackAnchorY: lastAnchorY,
|
|
455
268
|
});
|
|
456
|
-
|
|
457
|
-
if (Number.isFinite(anchorY)) {
|
|
458
|
-
lastAnchorY = Math.round(anchorY);
|
|
459
|
-
}
|
|
269
|
+
if (Number.isFinite(result.anchorY)) lastAnchorY = result.anchorY;
|
|
460
270
|
} catch (error) {
|
|
461
271
|
const errorEvent = createEvent({
|
|
462
272
|
tMs: nowMs(getNowMs) - startedAt,
|
|
463
273
|
action: 'phase_error',
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
detail: {
|
|
467
|
-
error: error?.message ?? 'unknown_error',
|
|
468
|
-
},
|
|
274
|
+
sectionId,
|
|
275
|
+
detail: { error: error?.message ?? 'unknown_error', code: error?.code ?? null },
|
|
469
276
|
});
|
|
470
277
|
eventsLog.push(errorEvent);
|
|
471
278
|
if (typeof onEvent === 'function') onEvent(errorEvent);
|
|
@@ -475,8 +282,7 @@ export async function executePlanPhases(page, plan, {
|
|
|
475
282
|
const endEvent = createEvent({
|
|
476
283
|
tMs: nowMs(getNowMs) - startedAt,
|
|
477
284
|
action: 'phase_end',
|
|
478
|
-
|
|
479
|
-
phaseAction,
|
|
285
|
+
sectionId,
|
|
480
286
|
});
|
|
481
287
|
eventsLog.push(endEvent);
|
|
482
288
|
if (typeof onEvent === 'function') onEvent(endEvent);
|