@lightcone-ai/daemon 0.21.0 → 0.22.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.
|
@@ -267,9 +267,9 @@ server.tool(
|
|
|
267
267
|
|
|
268
268
|
// ── compose_video_v2 (migrated from chat-bridge) ──────────────────────────
|
|
269
269
|
// Tool-level enforcement of the standard chain: TTS-bearing segments require
|
|
270
|
-
// plan_video_segments to have run earlier in this session. Without it
|
|
271
|
-
// dwell/duration math has repeatedly produced misaligned
|
|
272
|
-
// tails, and re-records
|
|
270
|
+
// plan_video_segments to have run earlier in this session. Without it,
|
|
271
|
+
// hand-written dwell/duration math has repeatedly produced misaligned
|
|
272
|
+
// subtitles, silent tails, and full re-records in production runs.
|
|
273
273
|
server.tool(
|
|
274
274
|
'compose_video_v2',
|
|
275
275
|
'Compose video(s) from a list of segments using ffmpeg. Each segment has a visual source (image / scroll / '
|
|
@@ -346,20 +346,16 @@ server.tool(
|
|
|
346
346
|
{
|
|
347
347
|
url: z.string().describe('Page URL to record'),
|
|
348
348
|
plan: z.record(z.any()).describe(
|
|
349
|
-
'A video plan: an object with `phases` (or `sections`), each a "visual beat"
|
|
350
|
-
+ '
|
|
351
|
-
+ '
|
|
352
|
-
+ '
|
|
353
|
-
+ '
|
|
354
|
-
+ '
|
|
355
|
-
+ '
|
|
356
|
-
+ '
|
|
357
|
-
+ '
|
|
358
|
-
+ '"届别说明"). Labels matching forbidden regions ("二维码" / "扫码" / "投递入口" / "投递方式" / '
|
|
359
|
-
+ '"联系方式" / "微信号" / "QR" / "阅读原文" / "外链") will cause the tool to refuse the '
|
|
360
|
-
+ 'recording — recruitment content must NOT dwell on these areas (see fragments.md '
|
|
361
|
-
+ 'frag.short.recruitment_url_mode_policy). Pick a different target_y in the 标题/岗位 '
|
|
362
|
-
+ 'information area and rewrite that section.'
|
|
349
|
+
'A video plan: an object with `phases` (or `sections`), each a "visual beat".\n\n'
|
|
350
|
+
+ 'ACTION VOCABULARY = atoms + macros. Pick by content type:\n'
|
|
351
|
+
+ ' - scroll_to_dwell (default for most sections): fast transition + dwell with subtle micro-motion at target_y. Use for titles, content cards, single focal areas.\n'
|
|
352
|
+
+ ' - narrated_pan: continuous linear scroll over the full section duration. Use ONLY when the speech actually narrates a long visible list (e.g. reading every job title in order). Was called linear_scroll_during; that name still works as an alias.\n'
|
|
353
|
+
+ ' - focal_arc: NO scroll; cursor moves between N visual focal points. Use for SHORT pages where consecutive sections share basically the same target_y (within ~150px) — scrolling would be invisible, the cursor carries the rhythm. Requires `points: [{x,y}, ...]` instead of target_y.\n'
|
|
354
|
+
+ ' - hold: pure pause, no motion. Rare.\n\n'
|
|
355
|
+
+ 'ATOMS (for power use via phase.beats[]): scroll_to / hold / micro_oscillate / cursor_focus. Any custom sequence the macros do not cover can be written as a beats array.\n\n'
|
|
356
|
+
+ 'Each section needs: action (or beats[]), target (`target_y` / `focus_region:[y1,y2]` / `points`), and `dwell_ms` (= section total duration; for narrated content this should match the segment\'s TTS audio_duration_ms).\n\n'
|
|
357
|
+
+ 'Standard chain: pass plan_video_segments\'s `segments` array directly as `plan.sections` — each segment\'s `dwell_ms` is already its `audio_duration_ms`.\n\n'
|
|
358
|
+
+ 'For RECRUITMENT URLs (mp.weixin.qq.com / 校招 / 实习 / 岗位 content), each section MUST also declare `target_y_content_label` — a short Chinese label describing what content sits at that pixel y position on the page (e.g. "标题区" / "岗位信息卡片" / "公司介绍" / "届别说明"). Labels matching forbidden regions ("二维码" / "扫码" / "投递入口" / "投递方式" / "联系方式" / "微信号" / "QR" / "阅读原文" / "外链") will cause the tool to refuse the recording — recruitment content must NOT dwell on these areas (see fragments.md frag.short.recruitment_url_mode_policy). Pick a different target_y in the 标题/岗位 information area and rewrite that section.'
|
|
363
359
|
),
|
|
364
360
|
output_paths: z.array(z.string()).min(1).describe('REQUIRED. Workspace-relative mp4 paths, one per plan.sections entry (single-section is a 1-element array). The tool records ONCE continuously and slices the result at section boundaries (derived from phase_start / phase_end events) — each section produces exactly one of these mp4s.'),
|
|
365
361
|
output_path: z.string().optional().describe('Optional debug-only path for the CONSOLIDATED master recording (the full continuous webm transcoded). Auto-generated under tmp/ if omitted. Agents normally do not need to set this — they consume output_paths.'),
|
package/package.json
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { resolveDurationMs } from './phase-duration.js';
|
|
2
|
-
import {
|
|
2
|
+
import { ATOMS, ATOM_NAMES } from './atoms.js';
|
|
3
|
+
import { MACROS, resolveMacroName } from './macros.js';
|
|
4
|
+
import { getCdpSession } from '../cdp-touch.js';
|
|
3
5
|
|
|
4
6
|
function normalizeText(value) {
|
|
5
7
|
if (typeof value !== 'string') return '';
|
|
@@ -22,16 +24,31 @@ function normalizeRange(value) {
|
|
|
22
24
|
return [low, high];
|
|
23
25
|
}
|
|
24
26
|
|
|
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.
|
|
27
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',
|
|
28
44
|
'hold',
|
|
45
|
+
'micro_oscillate',
|
|
46
|
+
'cursor_focus',
|
|
47
|
+
// legacy V2 names — kept for backward compat
|
|
29
48
|
'smooth_scroll',
|
|
30
49
|
'fast_scroll',
|
|
31
50
|
'linear_scroll_during',
|
|
32
|
-
'scroll_to_dwell',
|
|
33
51
|
'scroll_back',
|
|
34
|
-
'cursor_focus',
|
|
35
52
|
]);
|
|
36
53
|
|
|
37
54
|
// Common spellings authors reach for, mapped onto the canonical action above.
|
|
@@ -229,205 +246,182 @@ function resolveFromY(phase, fallback = null) {
|
|
|
229
246
|
return Math.round(parsed);
|
|
230
247
|
}
|
|
231
248
|
|
|
232
|
-
//
|
|
233
|
-
// the
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
targetY,
|
|
246
|
-
durationMs,
|
|
247
|
-
easing = 'easeInOutQuad',
|
|
248
|
-
jitterPx = 0,
|
|
249
|
-
// minSteps is accepted but unused — kept in the signature so callers don't
|
|
250
|
-
// need updating in this refactor.
|
|
251
|
-
minSteps: _minSteps, // eslint-disable-line no-unused-vars
|
|
252
|
-
} = {}) {
|
|
253
|
-
if (!Number.isFinite(Number(targetY))) {
|
|
254
|
-
const error = new Error('phase_target_y_required');
|
|
255
|
-
error.code = 'PHASE_TARGET_Y_REQUIRED';
|
|
256
|
-
throw error;
|
|
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 };
|
|
257
262
|
}
|
|
258
263
|
|
|
259
|
-
const
|
|
260
|
-
? Number(startY)
|
|
261
|
-
: await page.evaluate(() => {
|
|
262
|
-
const root = document.scrollingElement || document.documentElement;
|
|
263
|
-
return Math.round(root.scrollTop);
|
|
264
|
-
});
|
|
264
|
+
const action = resolvePhaseAction(phase);
|
|
265
265
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
}
|
|
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 };
|
|
273
|
+
}
|
|
274
274
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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 };
|
|
279
281
|
}
|
|
280
|
-
return { anchorY: null };
|
|
281
|
-
}
|
|
282
282
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
easing: 'easeInOutQuad',
|
|
290
|
-
jitterPx: 2,
|
|
291
|
-
minSteps: 18,
|
|
292
|
-
});
|
|
293
|
-
return { anchorY: targetY };
|
|
294
|
-
}
|
|
283
|
+
if (action === 'hold') {
|
|
284
|
+
return {
|
|
285
|
+
beats: [{ atom: 'hold', params: { duration_ms: resolveDurationMs(phase, 0) } }],
|
|
286
|
+
anchorY: null,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
295
289
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
easing: 'easeOutQuad',
|
|
303
|
-
jitterPx: 3,
|
|
304
|
-
minSteps: 10,
|
|
305
|
-
});
|
|
306
|
-
return { anchorY: targetY };
|
|
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;
|
|
307
296
|
}
|
|
308
297
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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) {
|
|
312
301
|
const durationMs = resolveDurationMs(phase, null);
|
|
313
|
-
if (
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
302
|
+
if (atomName === 'scroll_to') {
|
|
303
|
+
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),
|
|
309
|
+
};
|
|
317
310
|
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
startY: fromY,
|
|
321
|
-
targetY: toY,
|
|
322
|
-
durationMs,
|
|
323
|
-
easing: 'linear',
|
|
324
|
-
jitterPx: 0,
|
|
325
|
-
minSteps: 12,
|
|
326
|
-
});
|
|
327
|
-
|
|
328
|
-
const dwellMs = normalizeInteger(phase?.dwell_ms, null);
|
|
329
|
-
if (Number.isFinite(dwellMs) && dwellMs > durationMs) {
|
|
330
|
-
await page.waitForTimeout(dwellMs - durationMs);
|
|
311
|
+
if (atomName === 'hold') {
|
|
312
|
+
return { duration_ms: durationMs ?? 0 };
|
|
331
313
|
}
|
|
332
|
-
|
|
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
|
+
};
|
|
320
|
+
}
|
|
321
|
+
if (atomName === 'cursor_focus') {
|
|
322
|
+
return {
|
|
323
|
+
x: Number(phase.x ?? phase.cursor_x),
|
|
324
|
+
y: Number(phase.y ?? phase.cursor_y),
|
|
325
|
+
duration_ms: durationMs ?? 0,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
return { ...phase };
|
|
333
329
|
}
|
|
334
330
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
minSteps: 16,
|
|
344
|
-
});
|
|
345
|
-
const dwellMs = normalizeInteger(phase?.dwell_ms, null);
|
|
346
|
-
if (Number.isFinite(dwellMs) && dwellMs > 0) {
|
|
347
|
-
await page.waitForTimeout(dwellMs);
|
|
348
|
-
} else {
|
|
349
|
-
const holdMs = resolveDurationMs(phase, 0);
|
|
350
|
-
if (holdMs > 0) {
|
|
351
|
-
await page.waitForTimeout(holdMs);
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
return { anchorY: targetY };
|
|
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;
|
|
355
339
|
}
|
|
356
340
|
|
|
357
|
-
|
|
358
|
-
const
|
|
359
|
-
const
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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 : [] };
|
|
370
357
|
}
|
|
371
|
-
|
|
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
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return base;
|
|
372
368
|
}
|
|
373
369
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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;
|
|
393
|
+
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;
|
|
391
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
|
+
const result = await atomFn(page, ctx, params);
|
|
404
|
+
if (result?.anchorY != null) anchorY = result.anchorY;
|
|
392
405
|
}
|
|
393
|
-
return { anchorY
|
|
406
|
+
return { anchorY };
|
|
394
407
|
}
|
|
395
408
|
|
|
396
409
|
async function executePhase(page, phase, {
|
|
397
410
|
lastAnchorY = null,
|
|
398
411
|
initialAnchorY = 0,
|
|
399
412
|
} = {}) {
|
|
400
|
-
const action = resolvePhaseAction(phase);
|
|
401
413
|
const fallbackFromY = lastAnchorY ?? initialAnchorY;
|
|
414
|
+
const { beats, anchorY: macroAnchor } = phaseToBeats(phase, { fallbackFromY });
|
|
402
415
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
}
|
|
409
|
-
if (action === 'fast_scroll') {
|
|
410
|
-
return executeFastScroll(page, phase);
|
|
411
|
-
}
|
|
412
|
-
if (action === 'linear_scroll_during') {
|
|
413
|
-
return executeLinearScrollDuring(page, phase, { fallbackFromY });
|
|
414
|
-
}
|
|
415
|
-
if (action === 'scroll_to_dwell') {
|
|
416
|
-
return executeScrollToDwell(page, phase);
|
|
417
|
-
}
|
|
418
|
-
if (action === 'scroll_back') {
|
|
419
|
-
return executeScrollBack(page, phase, { fallbackTargetY: 0 });
|
|
420
|
-
}
|
|
421
|
-
if (action === 'cursor_focus') {
|
|
422
|
-
return executeCursorFocus(page, phase);
|
|
423
|
-
}
|
|
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 };
|
|
424
422
|
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
+ ' (there is no blind scroll_down/scroll_up; use scroll_to_dwell with target_y or focus_region)',
|
|
428
|
-
);
|
|
429
|
-
error.code = 'PHASE_ACTION_UNSUPPORTED';
|
|
430
|
-
throw error;
|
|
423
|
+
const { anchorY } = await executeBeats(page, ctx, beats, { fallbackFromY });
|
|
424
|
+
return { anchorY: anchorY ?? macroAnchor ?? null };
|
|
431
425
|
}
|
|
432
426
|
|
|
433
427
|
function createEvent({ tMs, action, phaseId, phaseAction, detail = {} }) {
|
|
@@ -87,10 +87,11 @@ function assertPipelineCompliance(plan) {
|
|
|
87
87
|
// would show 投递入口 / 二维码 / contact info, which violates the recruitment
|
|
88
88
|
// content policy (see fragments.md frag.short.recruitment_url_mode_policy).
|
|
89
89
|
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
90
|
+
// Origin: in production runs the agent's plan repeatedly declared a target_y
|
|
91
|
+
// without checking what content lived at that pixel position, and ended up
|
|
92
|
+
// dwelling on QR codes / 投递 entries / 联系方式. The prompt-level rule
|
|
93
|
+
// requiring `target_y_content_label` has been ignored often enough that we
|
|
94
|
+
// enforce it at the tool layer instead.
|
|
94
95
|
const FORBIDDEN_REGION_PATTERNS = [
|
|
95
96
|
/二维码/, /扫码/, /扫一扫/,
|
|
96
97
|
/投递入口/, /投递方式/, /投递通道/, /投递渠道/, /报名入口/, /报名方式/,
|
|
@@ -36,10 +36,10 @@ export const PART_RETRY_BASE_MS = 1_000; // 1s, 3s, 9s
|
|
|
36
36
|
export const TERMINAL_JOB_TTL_MS = 7 * 24 * 3600 * 1000; // sweep done/dead_letter after 7 days
|
|
37
37
|
export const HOUSEKEEPING_INTERVAL_MS = 6 * 3600 * 1000; // run housekeeping every 6h
|
|
38
38
|
// Per-PUT timeout — Node's fetch has no overall request timeout. Without this
|
|
39
|
-
// a stalled COS connection wedges the chunk loop forever (observed
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
39
|
+
// a stalled COS connection wedges the chunk loop forever (observed in
|
|
40
|
+
// production: a chunk PUT hung 7+ minutes with no progress and no error).
|
|
41
|
+
// 5 minutes covers slow networks for an 8MB chunk (~25kB/s floor) while
|
|
42
|
+
// still letting failures surface to the chunk-level retry loop.
|
|
43
43
|
export const PUT_REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
|
|
44
44
|
|
|
45
45
|
function nowIso() { return new Date().toISOString(); }
|