@lightcone-ai/daemon 0.14.0 → 0.14.2

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.
@@ -0,0 +1,377 @@
1
+ import { spawn } from 'node:child_process';
2
+ import path from 'node:path';
3
+ import { access, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
4
+ import { constants as fsConstants } from 'node:fs';
5
+ import os from 'node:os';
6
+
7
+ const MAX_STDERR_LENGTH = 4000;
8
+
9
+ const TRANSCODE_TARGETS = Object.freeze({
10
+ short_video_cn: {
11
+ width: 1080,
12
+ height: 1920,
13
+ fps: 30,
14
+ videoCodec: 'libx264',
15
+ profile: 'baseline',
16
+ pixelFormat: 'yuv420p',
17
+ crf: 23,
18
+ preset: 'veryfast',
19
+ level: '4.0',
20
+ audioCodec: 'aac',
21
+ audioBitrate: '128k',
22
+ audioSampleRate: 48000,
23
+ audioChannels: 2,
24
+ },
25
+ });
26
+
27
+ function normalizeText(value) {
28
+ if (typeof value !== 'string') return '';
29
+ return value.trim();
30
+ }
31
+
32
+ function normalizePath(value, label) {
33
+ const raw = normalizeText(value);
34
+ if (!raw) throw new Error(`${label} required`);
35
+ return path.resolve(raw);
36
+ }
37
+
38
+ async function ensureReadableFile(filePath, label) {
39
+ try {
40
+ await access(filePath, fsConstants.R_OK);
41
+ } catch {
42
+ throw new Error(`${label} not found or unreadable: ${filePath}`);
43
+ }
44
+
45
+ const st = await stat(filePath);
46
+ if (!st.isFile()) throw new Error(`${label} is not a file: ${filePath}`);
47
+ }
48
+
49
+ async function ensureParentDir(filePath) {
50
+ await mkdir(path.dirname(filePath), { recursive: true });
51
+ }
52
+
53
+ function sanitizeStderr(stderr) {
54
+ const text = String(stderr ?? '').trim();
55
+ if (!text) return '';
56
+ if (text.length <= MAX_STDERR_LENGTH) return text;
57
+ return text.slice(text.length - MAX_STDERR_LENGTH);
58
+ }
59
+
60
+ function toolError(prefix, error, stderr = '') {
61
+ const details = [];
62
+ const message = normalizeText(error?.message);
63
+ if (message) details.push(message);
64
+ const cleanedStderr = sanitizeStderr(stderr);
65
+ if (cleanedStderr) details.push(cleanedStderr);
66
+ const suffix = details.length > 0 ? `: ${details.join(' | ')}` : '';
67
+ return new Error(`${prefix}${suffix}`);
68
+ }
69
+
70
+ function runProcess(binary, args, { name = binary } = {}) {
71
+ return new Promise((resolve, reject) => {
72
+ const child = spawn(binary, args, { stdio: ['ignore', 'pipe', 'pipe'] });
73
+ let stdout = '';
74
+ let stderr = '';
75
+
76
+ child.stdout.on('data', (chunk) => {
77
+ stdout += chunk.toString();
78
+ });
79
+ child.stderr.on('data', (chunk) => {
80
+ stderr += chunk.toString();
81
+ });
82
+
83
+ child.on('error', (error) => {
84
+ reject(toolError(`${name} failed`, error, stderr));
85
+ });
86
+
87
+ child.on('close', (code) => {
88
+ if (code === 0) {
89
+ resolve({ stdout, stderr });
90
+ return;
91
+ }
92
+ reject(toolError(`${name} exited with code ${code}`, null, stderr));
93
+ });
94
+ });
95
+ }
96
+
97
+ function normalizeStartMs(value) {
98
+ const parsed = Number(value);
99
+ if (!Number.isFinite(parsed) || parsed < 0) return null;
100
+ return Math.floor(parsed);
101
+ }
102
+
103
+ function resolveStartMsFromEvents(segment, eventsLog = []) {
104
+ const phase = normalizeText(segment?.phase ?? segment?.phase_id ?? segment?.phaseId);
105
+ if (!phase) return null;
106
+
107
+ let candidate = null;
108
+ for (const event of eventsLog) {
109
+ const eventPhase = normalizeText(event?.phase ?? event?.phase_id ?? event?.phaseId);
110
+ if (!eventPhase || eventPhase !== phase) continue;
111
+
112
+ const eventStart = normalizeStartMs(
113
+ event?.t_ms_start
114
+ ?? event?.tMsStart
115
+ ?? event?.t_ms
116
+ ?? event?.tMs
117
+ );
118
+ if (eventStart == null) continue;
119
+ if (candidate == null || eventStart < candidate) candidate = eventStart;
120
+ }
121
+
122
+ return candidate;
123
+ }
124
+
125
+ function normalizeAudioSegments(audioSegments = [], eventsLog = []) {
126
+ if (!Array.isArray(audioSegments)) {
127
+ throw new Error('audio_segments must be an array');
128
+ }
129
+
130
+ return audioSegments.map((segment, index) => {
131
+ if (!segment || typeof segment !== 'object' || Array.isArray(segment)) {
132
+ throw new Error(`audio_segments[${index}] must be an object`);
133
+ }
134
+
135
+ const audioPath = normalizePath(segment.audio_path ?? segment.audioPath, `audio_segments[${index}].audio_path`);
136
+ const startMs = normalizeStartMs(segment.start_ms ?? segment.startMs)
137
+ ?? resolveStartMsFromEvents(segment, eventsLog);
138
+ if (startMs == null) {
139
+ throw new Error(`audio_segments[${index}].start_ms missing (and no matching events_log entry found)`);
140
+ }
141
+
142
+ return {
143
+ audioPath,
144
+ startMs,
145
+ };
146
+ });
147
+ }
148
+
149
+ function defaultOutputPath(inputPath, suffix) {
150
+ const ext = path.extname(inputPath) || '.mp4';
151
+ const base = path.basename(inputPath, ext);
152
+ return path.join(path.dirname(inputPath), `${base}.${suffix}${ext}`);
153
+ }
154
+
155
+ function escapeConcatPath(filePath) {
156
+ return filePath.replace(/'/g, `'\\''`);
157
+ }
158
+
159
+ export async function muxAudioToVideo({
160
+ video_path,
161
+ audio_segments = [],
162
+ events_log = [],
163
+ output = null,
164
+ } = {}) {
165
+ const videoPath = normalizePath(video_path, 'video_path');
166
+ await ensureReadableFile(videoPath, 'video_path');
167
+
168
+ const segments = normalizeAudioSegments(audio_segments, events_log).sort((a, b) => a.startMs - b.startMs);
169
+ for (const segment of segments) {
170
+ await ensureReadableFile(segment.audioPath, `audio segment (${segment.audioPath})`);
171
+ }
172
+
173
+ const outputPath = output
174
+ ? normalizePath(output, 'output')
175
+ : defaultOutputPath(videoPath, 'muxed');
176
+ if (outputPath === videoPath) throw new Error('output must not equal video_path');
177
+ await ensureParentDir(outputPath);
178
+
179
+ if (segments.length === 0) {
180
+ await runProcess('ffmpeg', [
181
+ '-y',
182
+ '-i', videoPath,
183
+ '-map', '0:v:0',
184
+ '-c:v', 'copy',
185
+ '-an',
186
+ outputPath,
187
+ ], { name: 'ffmpeg mux(no-audio)' });
188
+ return outputPath;
189
+ }
190
+
191
+ const filterChunks = [];
192
+ const mixInputs = [];
193
+ for (let i = 0; i < segments.length; i += 1) {
194
+ const delay = segments[i].startMs;
195
+ const label = `a${i}`;
196
+ filterChunks.push(`[${i + 1}:a]adelay=${delay}|${delay},aresample=async=1:first_pts=0[${label}]`);
197
+ mixInputs.push(`[${label}]`);
198
+ }
199
+ filterChunks.push(`${mixInputs.join('')}amix=inputs=${segments.length}:duration=longest:dropout_transition=0,apad[a]`);
200
+
201
+ const args = [
202
+ '-y',
203
+ '-i', videoPath,
204
+ ];
205
+
206
+ for (const segment of segments) {
207
+ args.push('-i', segment.audioPath);
208
+ }
209
+
210
+ args.push(
211
+ '-filter_complex', filterChunks.join(';'),
212
+ '-map', '0:v:0',
213
+ '-map', '[a]',
214
+ '-c:v', 'copy',
215
+ '-c:a', 'aac',
216
+ '-shortest',
217
+ '-movflags', '+faststart',
218
+ outputPath
219
+ );
220
+
221
+ await runProcess('ffmpeg', args, { name: 'ffmpeg mux' });
222
+ return outputPath;
223
+ }
224
+
225
+ export async function concatVideos({
226
+ inputs = [],
227
+ output,
228
+ } = {}) {
229
+ if (!Array.isArray(inputs) || inputs.length === 0) {
230
+ throw new Error('inputs must be a non-empty array');
231
+ }
232
+
233
+ const normalizedInputs = inputs.map((input, index) => normalizePath(input, `inputs[${index}]`));
234
+ for (const inputPath of normalizedInputs) {
235
+ await ensureReadableFile(inputPath, `concat input (${inputPath})`);
236
+ }
237
+
238
+ const outputPath = output
239
+ ? normalizePath(output, 'output')
240
+ : defaultOutputPath(normalizedInputs[0], 'concat');
241
+ await ensureParentDir(outputPath);
242
+
243
+ if (normalizedInputs.length === 1) {
244
+ await runProcess('ffmpeg', [
245
+ '-y',
246
+ '-i', normalizedInputs[0],
247
+ '-c', 'copy',
248
+ outputPath,
249
+ ], { name: 'ffmpeg concat(single-input)' });
250
+ return outputPath;
251
+ }
252
+
253
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), 'lightcone-concat-'));
254
+ const listPath = path.join(tempDir, 'inputs.txt');
255
+
256
+ try {
257
+ const content = normalizedInputs
258
+ .map(inputPath => `file '${escapeConcatPath(inputPath)}'`)
259
+ .join('\n');
260
+ await writeFile(listPath, `${content}\n`, 'utf8');
261
+
262
+ await runProcess('ffmpeg', [
263
+ '-y',
264
+ '-f', 'concat',
265
+ '-safe', '0',
266
+ '-i', listPath,
267
+ '-c', 'copy',
268
+ '-movflags', '+faststart',
269
+ outputPath,
270
+ ], { name: 'ffmpeg concat' });
271
+ } finally {
272
+ await rm(tempDir, { recursive: true, force: true }).catch(() => {});
273
+ }
274
+
275
+ return outputPath;
276
+ }
277
+
278
+ function resolveTranscodeTarget(target) {
279
+ const normalized = normalizeText(target).toLowerCase();
280
+ if (!normalized) return TRANSCODE_TARGETS.short_video_cn;
281
+
282
+ if (normalized === 'short_video_cn' || normalized === 'douyin' || normalized === 'xhs') {
283
+ return TRANSCODE_TARGETS.short_video_cn;
284
+ }
285
+
286
+ throw new Error(`unsupported transcode target: ${target}`);
287
+ }
288
+
289
+ export async function transcodeForPlatform({
290
+ input,
291
+ output,
292
+ target = 'short_video_cn',
293
+ } = {}) {
294
+ const inputPath = normalizePath(input, 'input');
295
+ await ensureReadableFile(inputPath, 'input');
296
+
297
+ const outputPath = output
298
+ ? normalizePath(output, 'output')
299
+ : defaultOutputPath(inputPath, 'platform');
300
+ if (outputPath === inputPath) throw new Error('output must not equal input');
301
+ await ensureParentDir(outputPath);
302
+
303
+ const preset = resolveTranscodeTarget(target);
304
+ const vf = [
305
+ `scale=${preset.width}:${preset.height}:force_original_aspect_ratio=decrease`,
306
+ `pad=${preset.width}:${preset.height}:(ow-iw)/2:(oh-ih)/2:black`,
307
+ 'setsar=1',
308
+ ].join(',');
309
+
310
+ await runProcess('ffmpeg', [
311
+ '-y',
312
+ '-i', inputPath,
313
+ '-vf', vf,
314
+ '-r', String(preset.fps),
315
+ '-c:v', preset.videoCodec,
316
+ '-profile:v', preset.profile,
317
+ '-level', preset.level,
318
+ '-pix_fmt', preset.pixelFormat,
319
+ '-preset', preset.preset,
320
+ '-crf', String(preset.crf),
321
+ '-c:a', preset.audioCodec,
322
+ '-b:a', preset.audioBitrate,
323
+ '-ar', String(preset.audioSampleRate),
324
+ '-ac', String(preset.audioChannels),
325
+ '-movflags', '+faststart',
326
+ outputPath,
327
+ ], { name: 'ffmpeg transcode' });
328
+
329
+ return outputPath;
330
+ }
331
+
332
+ export async function probeDurationMs(inputPath) {
333
+ const resolved = normalizePath(inputPath, 'input');
334
+ await ensureReadableFile(resolved, 'input');
335
+
336
+ const { stdout } = await runProcess('ffprobe', [
337
+ '-v', 'error',
338
+ '-show_entries', 'format=duration',
339
+ '-of', 'default=noprint_wrappers=1:nokey=1',
340
+ resolved,
341
+ ], { name: 'ffprobe duration' });
342
+
343
+ const seconds = Number.parseFloat(String(stdout ?? '').trim());
344
+ if (!Number.isFinite(seconds) || seconds <= 0) {
345
+ throw new Error(`ffprobe returned invalid duration for ${resolved}`);
346
+ }
347
+ return Math.floor(seconds * 1000);
348
+ }
349
+
350
+ export async function readMediaSpec(inputPath) {
351
+ const resolved = normalizePath(inputPath, 'input');
352
+ await ensureReadableFile(resolved, 'input');
353
+
354
+ const { stdout } = await runProcess('ffprobe', [
355
+ '-v', 'error',
356
+ '-select_streams', 'v:0',
357
+ '-show_entries', 'stream=width,height,r_frame_rate,pix_fmt,codec_name',
358
+ '-of', 'json',
359
+ resolved,
360
+ ], { name: 'ffprobe spec' });
361
+
362
+ let parsed;
363
+ try {
364
+ parsed = JSON.parse(stdout);
365
+ } catch {
366
+ throw new Error(`Failed to parse ffprobe spec output for ${resolved}`);
367
+ }
368
+
369
+ const stream = parsed?.streams?.[0] ?? {};
370
+ return {
371
+ width: Number(stream.width) || null,
372
+ height: Number(stream.height) || null,
373
+ frame_rate: String(stream.r_frame_rate ?? ''),
374
+ pixel_format: String(stream.pix_fmt ?? ''),
375
+ video_codec: String(stream.codec_name ?? ''),
376
+ };
377
+ }
@@ -16,6 +16,7 @@ import { injectWorkspaceContext } from './drivers/claude.js';
16
16
  import { parseKimiLine, encodeKimiStdin } from './drivers/kimi.js';
17
17
  import { startSession, stopSession, stopAllSessions } from './browser-login.js';
18
18
  import { markInvalidatedLeases } from './governance-state.js';
19
+ import { runPublishJob } from './publish-job-runner.js';
19
20
 
20
21
  const KIMI_SYSTEM_PROMPT_FILE = '.lightcone-kimi-system.md';
21
22
  const KIMI_AGENT_FILE = '.lightcone-kimi-agent.yaml';
@@ -156,6 +157,7 @@ export class AgentManager {
156
157
  case 'agent:start': return this._startAgent(msg, connection);
157
158
  case 'agent:stop': return this._stopAgent(msg.agentId, msg.workspaceId, connection);
158
159
  case 'agent:deliver': return this._deliverMessage(msg, connection);
160
+ case 'publish:job': return this._handlePublishJob(msg, connection);
159
161
  case 'browser:start_login': return this._startBrowserLogin(msg, connection);
160
162
  case 'browser:stop_login': return this._stopBrowserLogin(msg);
161
163
  case 'policy_invalidate': return this._handlePolicyInvalidate(msg, connection);
@@ -462,7 +464,12 @@ export class AgentManager {
462
464
  chatBridgePath,
463
465
  config,
464
466
  triggerType = 'resume',
467
+ triggerContext = null,
465
468
  }) {
469
+ const mergedTriggerContext = {
470
+ source: 'daemon-agent-manager',
471
+ ...(triggerContext && typeof triggerContext === 'object' ? triggerContext : {}),
472
+ };
466
473
  const res = await fetch(`${this.serverUrl}/governance/spawn-directive`, {
467
474
  method: 'POST',
468
475
  headers: {
@@ -474,7 +481,7 @@ export class AgentManager {
474
481
  workspace_id: workspaceId ?? null,
475
482
  trigger: {
476
483
  type: triggerType,
477
- context: { source: 'daemon-agent-manager' },
484
+ context: mergedTriggerContext,
478
485
  },
479
486
  runtime_context: {
480
487
  cli_type: runtime,
@@ -535,6 +542,10 @@ export class AgentManager {
535
542
  const nextDirective = await this._fetchSpawnDirective({
536
543
  ...context,
537
544
  triggerType: 'lease_refresh',
545
+ triggerContext: {
546
+ spawn_bundle_id: directive?.spawn_bundle_id ?? null,
547
+ policy_version: directive?.policy_version ?? null,
548
+ },
538
549
  });
539
550
  const agent = this.agents.get(key);
540
551
  if (!agent) return;
@@ -996,6 +1007,96 @@ export class AgentManager {
996
1007
  }
997
1008
  }
998
1009
 
1010
+ async _postInternalActionComplete({ agentId, actionId, ok, result, error }) {
1011
+ const url = `${this.serverUrl.replace(/\/$/, '')}/internal/agent/${encodeURIComponent(agentId)}/actions/${encodeURIComponent(actionId)}/complete`;
1012
+ const res = await fetch(url, {
1013
+ method: 'POST',
1014
+ headers: {
1015
+ 'Content-Type': 'application/json',
1016
+ Authorization: `Bearer ${this.machineApiKey}`,
1017
+ },
1018
+ body: JSON.stringify({ ok, result, error }),
1019
+ });
1020
+ if (!res.ok) {
1021
+ const text = await res.text();
1022
+ throw new Error(`action complete failed (${res.status}): ${text}`);
1023
+ }
1024
+ return res.json();
1025
+ }
1026
+
1027
+ async _handlePublishJob(msg, connection) {
1028
+ const job = normalizeObject(msg.job);
1029
+ const jobId = String(job.id ?? '').trim();
1030
+ const actionId = String(job.approval_action_id ?? '').trim();
1031
+ const agentId = String(job.agent_id ?? '').trim();
1032
+ const workspaceId = String(job.workspace_id ?? '').trim() || null;
1033
+
1034
+ if (!jobId || !actionId || !agentId) {
1035
+ console.warn('[AgentManager] publish:job missing required fields (id, approval_action_id, agent_id)');
1036
+ return;
1037
+ }
1038
+
1039
+ connection.send({
1040
+ type: 'publish:job_status',
1041
+ job_id: jobId,
1042
+ action_id: actionId,
1043
+ status: 'running',
1044
+ started_at: new Date().toISOString(),
1045
+ });
1046
+
1047
+ try {
1048
+ const workspaceDir = this._workspaceDir(agentId, workspaceId);
1049
+ const publishResult = await runPublishJob({
1050
+ serverUrl: this.serverUrl,
1051
+ machineApiKey: this.machineApiKey,
1052
+ agentId,
1053
+ workspaceDir,
1054
+ job,
1055
+ });
1056
+
1057
+ await this._postInternalActionComplete({
1058
+ agentId,
1059
+ actionId,
1060
+ ok: true,
1061
+ result: publishResult.completionResult,
1062
+ error: null,
1063
+ });
1064
+
1065
+ connection.send({
1066
+ type: 'publish:job_status',
1067
+ job_id: jobId,
1068
+ action_id: actionId,
1069
+ status: 'succeeded',
1070
+ completed_at: new Date().toISOString(),
1071
+ result: publishResult.completionResult,
1072
+ });
1073
+ } catch (err) {
1074
+ const errorMessage = err?.message ?? String(err);
1075
+ try {
1076
+ await this._postInternalActionComplete({
1077
+ agentId,
1078
+ actionId,
1079
+ ok: false,
1080
+ result: null,
1081
+ error: errorMessage,
1082
+ });
1083
+ } catch (completeErr) {
1084
+ console.error(
1085
+ `[AgentManager] Failed to report publish job completion for action=${actionId}: ${completeErr.message}`
1086
+ );
1087
+ }
1088
+
1089
+ connection.send({
1090
+ type: 'publish:job_status',
1091
+ job_id: jobId,
1092
+ action_id: actionId,
1093
+ status: 'failed',
1094
+ completed_at: new Date().toISOString(),
1095
+ error: errorMessage,
1096
+ });
1097
+ }
1098
+ }
1099
+
999
1100
  _stopAgent(agentId, workspaceId, connection) {
1000
1101
  const key = this._key(agentId, workspaceId);
1001
1102
  const agent = this.agents.get(key);