@lightcone-ai/daemon 0.22.1 → 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,480 +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
- // Number(null) === 0 (a JS gotcha) guard explicitly so a normalize step
333
- // that fills missing fields with `null` (see normalizeSectionAsPhase) doesn't
334
- // trick `Number.isFinite` into accepting a phantom zero.
335
- function optionalFiniteNumber(value) {
336
- if (value == null) return undefined;
337
- const n = Number(value);
338
- return Number.isFinite(n) ? n : undefined;
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 paramsForMacro(macroName, phase, fallbackFromY) {
342
- const durationMs = resolveDurationMs(phase, null);
343
- const targetY = resolveTargetY(phase, null);
344
- const fromY = resolveFromY(phase, fallbackFromY);
345
- const base = {
346
- target_y: targetY,
347
- duration_ms: durationMs,
348
- from_y: fromY,
349
- transition_ms: optionalFiniteNumber(phase.transition_ms),
350
- transition_ratio: optionalFiniteNumber(phase.transition_ratio),
351
- transition_curve: phase.transition_curve,
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 base;
195
+ return sections.map((section, index) => normalizeSection(section, index));
368
196
  }
369
197
 
370
- // Run an atom-sequence in order. Each beat is either:
371
- // - an atom (run directly through ATOMS[name])
372
- // - a macro reference (expand to atoms inline; one level of nesting only —
373
- // macros calling macros breaks the "shortcut for common atom combo"
374
- // mental model).
375
- async function executeBeats(page, ctx, beats, { fallbackFromY = 0 } = {}) {
376
- let anchorY = fallbackFromY;
377
- for (const beat of beats) {
378
- if (!beat || typeof beat !== 'object') continue;
379
- if (beat.macro) {
380
- const macroFn = MACROS[beat.macro] || MACROS[resolveMacroName(beat.macro)];
381
- if (!macroFn) {
382
- const error = new Error(`beat_macro_unsupported:${beat.macro}`);
383
- error.code = 'BEAT_MACRO_UNSUPPORTED';
384
- throw error;
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
- async function executePhase(page, phase, {
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: phaseId,
432
- phase_action: phaseAction,
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 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
+
442
242
  const startedAt = nowMs(getNowMs);
443
243
  const eventsLog = [];
444
244
  let lastAnchorY = 0;
445
245
 
446
- for (let index = 0; index < phases.length; index += 1) {
447
- const phase = phases[index];
448
- const phaseId = resolvePhaseId(phase, index);
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
- phaseId,
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 executionResult = await executePhase(page, phase, {
462
- lastAnchorY,
463
- initialAnchorY: 0,
263
+ const result = await runOperations(page, ctx, section.operations, {
264
+ fullHeightPx,
265
+ unsafeRegions,
266
+ sectionId,
267
+ fallbackAnchorY: lastAnchorY,
464
268
  });
465
- const anchorY = Number(executionResult?.anchorY);
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
- phaseId,
474
- phaseAction,
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
- phaseId,
488
- phaseAction,
285
+ sectionId,
489
286
  });
490
287
  eventsLog.push(endEvent);
491
288
  if (typeof onEvent === 'function') onEvent(endEvent);