@lightcone-ai/daemon 0.21.0 → 0.22.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.
|
@@ -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,173 @@ 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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
await animateScroll(page, {
|
|
339
|
-
targetY,
|
|
340
|
-
durationMs: transitionMs,
|
|
341
|
-
easing: 'easeInOutQuad',
|
|
342
|
-
jitterPx: 2,
|
|
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
|
-
}
|
|
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
|
+
};
|
|
353
320
|
}
|
|
354
|
-
|
|
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 };
|
|
355
329
|
}
|
|
356
330
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
const
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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 : [] };
|
|
370
348
|
}
|
|
371
|
-
|
|
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
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return base;
|
|
372
359
|
}
|
|
373
360
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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;
|
|
384
|
+
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;
|
|
391
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
|
+
const result = await atomFn(page, ctx, params);
|
|
395
|
+
if (result?.anchorY != null) anchorY = result.anchorY;
|
|
392
396
|
}
|
|
393
|
-
return { anchorY
|
|
397
|
+
return { anchorY };
|
|
394
398
|
}
|
|
395
399
|
|
|
396
400
|
async function executePhase(page, phase, {
|
|
397
401
|
lastAnchorY = null,
|
|
398
402
|
initialAnchorY = 0,
|
|
399
403
|
} = {}) {
|
|
400
|
-
const action = resolvePhaseAction(phase);
|
|
401
404
|
const fallbackFromY = lastAnchorY ?? initialAnchorY;
|
|
405
|
+
const { beats, anchorY: macroAnchor } = phaseToBeats(phase, { fallbackFromY });
|
|
402
406
|
|
|
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
|
-
}
|
|
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 };
|
|
424
413
|
|
|
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;
|
|
414
|
+
const { anchorY } = await executeBeats(page, ctx, beats, { fallbackFromY });
|
|
415
|
+
return { anchorY: anchorY ?? macroAnchor ?? null };
|
|
431
416
|
}
|
|
432
417
|
|
|
433
418
|
function createEvent({ tMs, action, phaseId, phaseAction, detail = {} }) {
|