@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.
@@ -1,471 +1,278 @@
1
- import { resolveDurationMs } from './phase-duration.js';
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 { MACROS, resolveMacroName } from './macros.js';
17
+ import { findOverlappingUnsafeRegion } from '../understanding/schema.js';
4
18
  import { getCdpSession } from '../cdp-touch.js';
5
19
 
6
- function normalizeText(value) {
7
- if (typeof value !== 'string') return '';
8
- return value.trim();
9
- }
10
-
11
- function normalizeInteger(value, fallback = null) {
12
- const parsed = Number.parseInt(String(value ?? ''), 10);
13
- if (!Number.isFinite(parsed)) return fallback;
14
- return parsed;
15
- }
16
-
17
- function normalizeRange(value) {
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 normalizeActionName(rawValue) {
83
- const name = normalizeText(rawValue).toLowerCase();
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
- // `visual_action` may be a string (the action name) or an object ({type, target_y, ...}).
90
- function visualActionObject(section = {}) {
91
- const va = section?.visual_action;
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 inferActionFromCameraMotion(phase = {}) {
98
- const motion = normalizeText(phase.camera_motion ?? phase.cameraMotion).toLowerCase();
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 pickFirstNumber(...values) {
107
- for (const value of values) {
108
- if (value == null) continue;
109
- const parsed = Number(value);
110
- if (Number.isFinite(parsed)) return Math.round(parsed);
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
- export function normalizePlanPhases(plan = {}) {
152
- const topLevelPhases = Array.isArray(plan?.phases) ? plan.phases : [];
153
- if (topLevelPhases.length > 0) {
154
- return topLevelPhases.map((phase, index) => normalizeSectionAsPhase(phase, index));
155
- }
156
-
157
- const sections = Array.isArray(plan?.sections) ? plan.sections : [];
158
- if (sections.length > 0) {
159
- const flattened = [];
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
- const error = new Error('plan_phases_required');
179
- error.code = 'PLAN_PHASES_REQUIRED';
180
- throw error;
181
- }
182
-
183
- function resolvePhaseAction(phase = {}) {
184
- const explicit = normalizeActionName(
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 requireTargetY(phase, action) {
229
- const targetY = resolveTargetY(phase, null);
230
- if (targetY == null) {
82
+ function assertYNotInUnsafeRegion(y, { unsafeRegions, atomName, sectionId, operationIndex }) {
83
+ const hit = findOverlappingUnsafeRegion(unsafeRegions, y);
84
+ if (hit) {
231
85
  const error = new Error(
232
- `phase_target_y_required: phase "${resolvePhaseId(phase)}" uses "${action}" but has no `
233
- + 'target_y / to_y / y or focus_region — every scroll phase must say where it lands '
234
- + '(there is no blind scroll)',
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 = 'PHASE_TARGET_Y_REQUIRED';
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
- // ── beat dispatch ─────────────────────────────────────────────────────────
250
- // A "beat" is one entry in the atom sequence run during a phase. Either:
251
- // { atom: 'scroll_to', params: {...} } — leaf, dispatched directly
252
- // { macro: 'scroll_to_dwell', params } expanded to atoms then run
253
- // plan-executor stays thin: turn phase into beats, then run beats.
254
-
255
- // Build the atom-sequence for a phase. Two modes:
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
- const action = resolvePhaseAction(phase);
265
-
266
- if (ATOM_NAMES.includes(action)) {
267
- const params = paramsForAtom(action, phase, fallbackFromY);
268
- const beat = { atom: action, params };
269
- const anchorY = action === 'scroll_to' && Number.isFinite(Number(params.target_y))
270
- ? Number(params.target_y)
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 macroName = resolveMacroName(action);
276
- if (macroName) {
277
- const macroFn = MACROS[macroName];
278
- const params = paramsForMacro(macroName, phase, fallbackFromY);
279
- const expanded = macroFn(params, { fromY: fallbackFromY });
280
- return { beats: expanded.beats, anchorY: expanded.anchorY };
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 (action === 'hold') {
284
- return {
285
- beats: [{ atom: 'hold', params: { duration_ms: resolveDurationMs(phase, 0) } }],
286
- anchorY: null,
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
- // Phase fields atom params. Atom-specific shape; missing fields fall back
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: requireTargetY(phase, 'scroll_to'),
305
- duration_ms: durationMs ?? resolveTransitionMs(phase, 600),
306
- curve: phase.curve || phase.motion_curve || 'easeInOutQuad',
307
- jitter_px: Number.isFinite(Number(phase.jitter_px)) ? Number(phase.jitter_px) : 2,
308
- from_y: resolveFromY(phase, fallbackFromY),
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: durationMs ?? 0 };
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(phase.x ?? phase.cursor_x),
324
- y: Number(phase.y ?? phase.cursor_y),
325
- duration_ms: durationMs ?? 0,
156
+ x: Number(op.x),
157
+ y: Number(op.y),
158
+ duration_ms: Number(op.duration_ms),
326
159
  };
327
160
  }
328
- return { ...phase };
161
+ return {};
329
162
  }
330
163
 
331
- // Phase fields → macro params. Macros validate their own inputs.
332
- function paramsForMacro(macroName, phase, fallbackFromY) {
333
- const durationMs = resolveDurationMs(phase, null);
334
- const targetY = resolveTargetY(phase, null);
335
- const fromY = resolveFromY(phase, fallbackFromY);
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
- if (macroName === 'scroll_to_dwell') {
350
- // The legacy fast_scroll alias maps to scroll_to_dwell, but with a
351
- // shorter default transition_ratio so the snap feels closer to the
352
- // original fast_scroll behavior.
353
- const isFastScrollAlias = String(phase.action || '').toLowerCase() === 'fast_scroll';
354
- if (isFastScrollAlias && base.transition_ratio === undefined) {
355
- base.transition_ratio = 0.1;
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 base;
180
+ return { ...section, id, phase_id: id };
359
181
  }
360
182
 
361
- // Run an atom-sequence in order. Each beat is either:
362
- // - an atom (run directly through ATOMS[name])
363
- // - a macro reference (expand to atoms inline; one level of nesting only —
364
- // macros calling macros breaks the "shortcut for common atom combo"
365
- // mental model).
366
- async function executeBeats(page, ctx, beats, { fallbackFromY = 0 } = {}) {
367
- let anchorY = fallbackFromY;
368
- for (const beat of beats) {
369
- if (!beat || typeof beat !== 'object') continue;
370
- if (beat.macro) {
371
- const macroFn = MACROS[beat.macro] || MACROS[resolveMacroName(beat.macro)];
372
- if (!macroFn) {
373
- const error = new Error(`beat_macro_unsupported:${beat.macro}`);
374
- error.code = 'BEAT_MACRO_UNSUPPORTED';
375
- throw error;
376
- }
377
- const expanded = macroFn(beat.params ?? {}, { fromY: anchorY });
378
- const sub = await executeBeats(page, ctx, expanded.beats, { fallbackFromY: anchorY });
379
- if (sub?.anchorY != null) anchorY = sub.anchorY;
380
- else if (expanded.anchorY != null) anchorY = expanded.anchorY;
381
- continue;
382
- }
383
- const atomName = beat.atom;
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
- async function executePhase(page, phase, {
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: phaseId,
423
- phase_action: phaseAction,
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 phases = normalizePlanPhases(plan);
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 < phases.length; index += 1) {
438
- const phase = phases[index];
439
- const phaseId = resolvePhaseId(phase, index);
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
- phaseId,
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 executionResult = await executePhase(page, phase, {
453
- lastAnchorY,
454
- initialAnchorY: 0,
263
+ const result = await runOperations(page, ctx, section.operations, {
264
+ fullHeightPx,
265
+ unsafeRegions,
266
+ sectionId,
267
+ fallbackAnchorY: lastAnchorY,
455
268
  });
456
- const anchorY = Number(executionResult?.anchorY);
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
- phaseId,
465
- phaseAction,
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
- phaseId,
479
- phaseAction,
285
+ sectionId,
480
286
  });
481
287
  eventsLog.push(endEvent);
482
288
  if (typeof onEvent === 'function') onEvent(endEvent);