@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.
@@ -2,9 +2,21 @@
2
2
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
3
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
4
  import { z } from 'zod';
5
- import { readFileSync } from 'fs';
5
+ import { createReadStream, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
6
6
  import { createHash, randomUUID } from 'crypto';
7
7
  import path, { extname } from 'path';
8
+ import { homedir } from 'os';
9
+ import {
10
+ concatVideos,
11
+ muxAudioToVideo,
12
+ probeDurationMs,
13
+ transcodeForPlatform,
14
+ } from './_vendor/video/composer/index.js';
15
+ import {
16
+ VIDEO_EXT,
17
+ VIDEO_MIME,
18
+ writeLocalFileToWorkspace,
19
+ } from './workspace-file-upload.js';
8
20
  import { isLeaseInvalidated, clearInvalidatedLease } from './governance-state.js';
9
21
  import { classifyLeaseWindow } from './lease-window.js';
10
22
 
@@ -41,14 +53,9 @@ const BUNDLE_EVENT_FLUSH_MS = Number(process.env.GOVERNANCE_BUNDLE_FLUSH_MS ?? 2
41
53
  // Current active workspaceId for memory isolation (defaults to spawn-time WORKSPACE_ID)
42
54
  let currentWorkspaceId = WORKSPACE_ID;
43
55
 
44
- const WORKSPACE_BINARY_MIME = {
45
- '.png': 'image/png',
46
- '.jpg': 'image/jpeg',
47
- '.jpeg': 'image/jpeg',
48
- '.webp': 'image/webp',
49
- '.gif': 'image/gif',
50
- '.pdf': 'application/pdf',
51
- };
56
+ const VOICEOVER_LOCAL_DIR = path.join(WORKSPACE_DIR, 'artifacts', 'audio');
57
+ const VIDEO_COMPOSE_LOCAL_DIR = path.join(WORKSPACE_DIR, 'artifacts', 'video');
58
+ const DEFAULT_OUTRO_PATH = path.join(homedir(), '.lightcone', 'assets', 'outros', 'default.mp4');
52
59
 
53
60
  function dataUrlSummary(content) {
54
61
  if (typeof content !== 'string' || !content.startsWith('data:')) return null;
@@ -73,6 +80,27 @@ function formatBytes(bytes) {
73
80
  return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
74
81
  }
75
82
 
83
+ function normalizeVoiceFormat(value) {
84
+ const normalized = String(value ?? '').trim().toLowerCase();
85
+ if (!normalized) return 'mp3';
86
+ if (['mp3', 'wav', 'flac'].includes(normalized)) return normalized;
87
+ return 'mp3';
88
+ }
89
+
90
+ function inferAudioExtension(url, format = 'mp3') {
91
+ const normalizedFormat = normalizeVoiceFormat(format);
92
+ if (typeof url === 'string' && url.trim()) {
93
+ try {
94
+ const pathname = new URL(url).pathname;
95
+ const ext = extname(pathname).toLowerCase();
96
+ if (ext && ['.mp3', '.wav', '.flac'].includes(ext)) return ext;
97
+ } catch {
98
+ // noop
99
+ }
100
+ }
101
+ return `.${normalizedFormat}`;
102
+ }
103
+
76
104
  function isInsideDir(filePath, dir) {
77
105
  const rel = path.relative(dir, filePath);
78
106
  return rel === '' || (!!rel && !rel.startsWith('..') && !path.isAbsolute(rel));
@@ -88,6 +116,58 @@ function resolveLocalWorkspaceFile(filePath) {
88
116
  throw new Error(`Local file must be inside the agent workspace or workspace shared artifacts/notes/tmp directories. Got: ${filePath}`);
89
117
  }
90
118
 
119
+ function normalizeComposePath(filePath, label) {
120
+ const normalized = String(filePath ?? '').trim();
121
+ if (!normalized) throw new Error(`${label} is required.`);
122
+ return path.resolve(WORKSPACE_DIR, normalized);
123
+ }
124
+
125
+ function normalizeComposeTarget(value) {
126
+ const normalized = String(value ?? '').trim().toLowerCase();
127
+ if (!normalized) return 'short_video_cn';
128
+ if (['short_video_cn', 'douyin', 'xhs'].includes(normalized)) return normalized;
129
+ throw new Error(`Unsupported compose target: ${value}`);
130
+ }
131
+
132
+ function normalizeComposeAudioSegments(audioSegments) {
133
+ if (!Array.isArray(audioSegments) || audioSegments.length === 0) {
134
+ throw new Error('audio_segments must be a non-empty array.');
135
+ }
136
+
137
+ return audioSegments.map((segment, index) => {
138
+ if (!segment || typeof segment !== 'object' || Array.isArray(segment)) {
139
+ throw new Error(`audio_segments[${index}] must be an object.`);
140
+ }
141
+ const audioPath = normalizeComposePath(
142
+ segment.audio_path ?? segment.audioPath,
143
+ `audio_segments[${index}].audio_path`
144
+ );
145
+ const startMsRaw = segment.start_ms ?? segment.startMs;
146
+ const startMs = startMsRaw == null ? null : Number(startMsRaw);
147
+ if (startMsRaw != null && (!Number.isFinite(startMs) || startMs < 0)) {
148
+ throw new Error(`audio_segments[${index}].start_ms must be a non-negative number.`);
149
+ }
150
+ const phase = String(segment.phase ?? segment.phase_id ?? segment.phaseId ?? '').trim();
151
+ if (startMs == null && !phase) {
152
+ throw new Error(`audio_segments[${index}] requires start_ms, or provide phase with events_log.`);
153
+ }
154
+
155
+ const normalized = { audio_path: audioPath };
156
+ if (startMs != null) normalized.start_ms = Math.floor(startMs);
157
+ if (phase) normalized.phase = phase;
158
+ return {
159
+ ...normalized,
160
+ };
161
+ });
162
+ }
163
+
164
+ function cleanupLocalFiles(paths = []) {
165
+ for (const filePath of paths) {
166
+ if (!filePath) continue;
167
+ try { rmSync(filePath, { force: true }); } catch { /* noop */ }
168
+ }
169
+ }
170
+
91
171
  const DEFAULT_TOOL_CLASSIFICATION = {
92
172
  check_messages: 'local',
93
173
  list_memory: 'local',
@@ -115,6 +195,8 @@ const DEFAULT_TOOL_CLASSIFICATION = {
115
195
  update_goal_field: 'mandatory',
116
196
  supersede_goal_field: 'mandatory',
117
197
  request_credential_auth: 'mandatory',
198
+ generate_voiceover: 'mandatory',
199
+ compose_video: 'mandatory',
118
200
  register_data_source: 'mandatory',
119
201
  bind_workspace_scenario: 'mandatory',
120
202
  create_workspace: 'mandatory',
@@ -248,6 +330,7 @@ function inferToolForApi(method, apiPath, body) {
248
330
  if (method === 'POST' && cleanPath === '/goal-fields/update') return 'update_goal_field';
249
331
  if (method === 'POST' && cleanPath === '/goal-fields/supersede') return 'supersede_goal_field';
250
332
  if (method === 'POST' && cleanPath === '/credential-auth/request') return 'request_credential_auth';
333
+ if (method === 'POST' && cleanPath === '/tts/voiceover') return 'generate_voiceover';
251
334
  if (method === 'POST' && cleanPath === '/api/data-sources') return 'register_data_source';
252
335
  if (method === 'POST' && cleanPath === '/orchestrate/decision') return 'write_governance_decision';
253
336
  if (method === 'POST' && cleanPath === '/orchestrate/correction') return 'write_governance_correction';
@@ -420,6 +503,32 @@ async function directApi(method, apiPath, body) {
420
503
  return res.json();
421
504
  }
422
505
 
506
+ async function directApiVideoUpload(apiPath, {
507
+ localPath,
508
+ filename,
509
+ contentType = VIDEO_MIME,
510
+ }) {
511
+ const url = `${SERVER_URL}/internal/agent/${AGENT_ID}${apiPath}`;
512
+ const headers = {
513
+ 'Authorization': `Bearer ${MACHINE_API_KEY}`,
514
+ 'Content-Type': contentType,
515
+ };
516
+ if (filename) headers['X-File-Name'] = filename;
517
+
518
+ const res = await fetch(url, {
519
+ method: 'POST',
520
+ headers,
521
+ body: createReadStream(localPath),
522
+ duplex: 'half',
523
+ });
524
+
525
+ if (!res.ok) {
526
+ const text = await res.text();
527
+ throw new Error(`API POST ${apiPath} → ${res.status}: ${text}`);
528
+ }
529
+ return res.json();
530
+ }
531
+
423
532
  async function callGovernance(payload, { retry = true } = {}) {
424
533
  const attempts = retry ? 2 : 1;
425
534
  let lastError = null;
@@ -507,6 +616,61 @@ async function governanceRoundTrip({ method, apiPath, body, toolName, classifica
507
616
  return directApi(method, apiPath, nextBody);
508
617
  }
509
618
 
619
+ async function runMandatoryLocalTool({ toolName, toolInput = {}, executor }) {
620
+ const classification = TOOL_CLASSIFICATION[toolName] ?? 'mandatory';
621
+ const traceId = randomUUID();
622
+ enqueueBundleEvent('tool_call_started', {
623
+ trace_id: traceId,
624
+ tool_name: toolName,
625
+ tool_classification: classification,
626
+ method: 'LOCAL',
627
+ api_path: '/local-tool',
628
+ });
629
+
630
+ try {
631
+ await ensureGovernanceContext();
632
+ const governancePayload = {
633
+ spawn_bundle_id: governanceContext.spawnBundleId,
634
+ policy_version: governanceContext.policyVersion,
635
+ tool_name: toolName,
636
+ tool_input: toolInput,
637
+ tool_classification: classification,
638
+ agent_id: AGENT_ID,
639
+ idempotency_key: randomUUID(),
640
+ lease_id: governanceContext.lease?.lease_id ?? null,
641
+ };
642
+ const governance = await callGovernance(governancePayload, { retry: true });
643
+ if (governance.policy_lease) applyPolicyLease(governance.policy_lease);
644
+ if (governance.verdict === 'reject' || governance.verdict === 'defer_human') {
645
+ throw governanceError(governanceReasonCode(governance.reason));
646
+ }
647
+
648
+ const checkedInput = (governance.verdict === 'modify' && governance.modified_input && typeof governance.modified_input === 'object')
649
+ ? { ...toolInput, ...governance.modified_input }
650
+ : toolInput;
651
+ const result = await executor(checkedInput);
652
+ if (CACHE_INVALIDATION_TOOLS.has(toolName)) {
653
+ governanceContext.cache.clear();
654
+ }
655
+
656
+ enqueueBundleEvent('tool_call_succeeded', {
657
+ trace_id: traceId,
658
+ tool_name: toolName,
659
+ tool_classification: classification,
660
+ source: 'governance_roundtrip',
661
+ });
662
+ return result;
663
+ } catch (error) {
664
+ enqueueBundleEvent('tool_call_failed', {
665
+ trace_id: traceId,
666
+ tool_name: toolName,
667
+ tool_classification: classification,
668
+ reason: error?.code ?? error?.message ?? 'unknown_error',
669
+ });
670
+ throw error;
671
+ }
672
+ }
673
+
510
674
  function renewCacheInBackground({ method, apiPath, body, toolName, cacheKey }) {
511
675
  if (governanceContext.renewalInFlight.has(cacheKey)) return;
512
676
  governanceContext.renewalInFlight.add(cacheKey);
@@ -924,12 +1088,54 @@ server.tool('write_workspace_file', 'Write a local file directly to the shared w
924
1088
  }, async ({ file_path, path }) => {
925
1089
  if (!currentWorkspaceId) return { content: [{ type: 'text', text: 'No workspace context.' }] };
926
1090
  const localPath = resolveLocalWorkspaceFile(file_path);
927
- const ext = extname(path || localPath).toLowerCase();
928
- const mime = WORKSPACE_BINARY_MIME[ext] ?? 'application/octet-stream';
929
- const buf = readFileSync(localPath);
930
- const content = `data:${mime};base64,${buf.toString('base64')}`;
931
- await api('PUT', `/workspace-memory?path=${encodeURIComponent(path)}&workspaceId=${encodeURIComponent(currentWorkspaceId)}`, { content });
932
- return { content: [{ type: 'text', text: `Saved local file to workspace: ${path} (${mime}, ${formatBytes(buf.length)})` }] };
1091
+ const result = await writeLocalFileToWorkspace({
1092
+ localPath,
1093
+ workspacePath: path,
1094
+ workspaceId: currentWorkspaceId,
1095
+ readFileSyncFn: readFileSync,
1096
+ uploadWorkspaceMemory: async ({ workspacePath, workspaceId, content }) => {
1097
+ await api('PUT', `/workspace-memory?path=${encodeURIComponent(workspacePath)}&workspaceId=${encodeURIComponent(workspaceId)}`, { content });
1098
+ },
1099
+ uploadVideo: async ({
1100
+ localPath: videoLocalPath,
1101
+ workspacePath,
1102
+ workspaceId,
1103
+ filename,
1104
+ mime,
1105
+ }) => {
1106
+ const uploadResult = await runMandatoryLocalTool({
1107
+ toolName: 'write_workspace_file',
1108
+ toolInput: {
1109
+ file_path,
1110
+ path: workspacePath,
1111
+ upload_mode: 'upload_video',
1112
+ },
1113
+ executor: async () => directApiVideoUpload(
1114
+ `/upload-video?workspaceId=${encodeURIComponent(workspaceId)}&filename=${encodeURIComponent(filename)}&path=${encodeURIComponent(workspacePath)}`,
1115
+ {
1116
+ localPath: videoLocalPath,
1117
+ filename,
1118
+ contentType: mime,
1119
+ }
1120
+ ),
1121
+ });
1122
+ return {
1123
+ mode: 'upload-video',
1124
+ mime: VIDEO_MIME,
1125
+ bytes: Number(uploadResult?.sizeBytes) || 0,
1126
+ };
1127
+ },
1128
+ });
1129
+
1130
+ const finalMime = result?.mime ?? (extname(path || localPath).toLowerCase() === VIDEO_EXT ? VIDEO_MIME : 'application/octet-stream');
1131
+ const finalBytes = Number.isFinite(result?.bytes) ? result.bytes : 0;
1132
+ const sizeText = finalBytes > 0 ? formatBytes(finalBytes) : 'unknown size';
1133
+ return {
1134
+ content: [{
1135
+ type: 'text',
1136
+ text: `Saved local file to workspace: ${path} (${finalMime}, ${sizeText})`,
1137
+ }],
1138
+ };
933
1139
  });
934
1140
 
935
1141
  // ── skill_list ───────────────────────────────────────────────────────────────
@@ -1120,6 +1326,183 @@ server.tool('request_credential_auth',
1120
1326
  }
1121
1327
  );
1122
1328
 
1329
+ // ── generate_voiceover ─────────────────────────────────────────────────────────
1330
+ server.tool('generate_voiceover',
1331
+ 'Generate a TTS voiceover using an authorized tts_provider credential and return a local audio file path.',
1332
+ {
1333
+ workspace_id: z.string().optional().describe('Target workspace id. Defaults to current workspace context.'),
1334
+ text: z.string().describe('Text content to synthesize.'),
1335
+ voice_preset: z.string().optional().describe('Platform-neutral voice preset id, e.g. "warm_female_zh_01".'),
1336
+ speed: z.number().optional().describe('Speech speed. Typical range is 0.5 to 2.0.'),
1337
+ format: z.enum(['mp3', 'wav', 'flac']).optional().describe('Audio format. Defaults to mp3.'),
1338
+ credential_id: z.string().optional().describe('Optional explicit credential id. If omitted, uses latest granted tts_provider credential.'),
1339
+ },
1340
+ async ({ workspace_id, text, voice_preset, speed, format, credential_id }) => {
1341
+ const targetWorkspaceId = (workspace_id ?? currentWorkspaceId ?? WORKSPACE_ID ?? '').trim();
1342
+ if (!targetWorkspaceId) {
1343
+ return { isError: true, content: [{ type: 'text', text: 'workspace_id is required (no current workspace context).' }] };
1344
+ }
1345
+
1346
+ const normalizedText = String(text ?? '').trim();
1347
+ if (!normalizedText) {
1348
+ return { isError: true, content: [{ type: 'text', text: 'text is required for generate_voiceover.' }] };
1349
+ }
1350
+
1351
+ const normalizedSpeed = speed == null ? 1 : Number(speed);
1352
+ if (!Number.isFinite(normalizedSpeed)) {
1353
+ return { isError: true, content: [{ type: 'text', text: 'speed must be numeric.' }] };
1354
+ }
1355
+
1356
+ const normalizedFormat = normalizeVoiceFormat(format);
1357
+ const payload = {
1358
+ workspace_id: targetWorkspaceId,
1359
+ text: normalizedText,
1360
+ speed: normalizedSpeed,
1361
+ format: normalizedFormat,
1362
+ };
1363
+ if (voice_preset) payload.voice_preset = String(voice_preset).trim();
1364
+ if (credential_id) payload.credential_id = String(credential_id).trim();
1365
+
1366
+ const data = await api('POST', '/tts/voiceover', payload);
1367
+ const remoteAudioUrl = String(data.audio_url ?? '').trim();
1368
+ if (!remoteAudioUrl) {
1369
+ return { isError: true, content: [{ type: 'text', text: 'Voiceover API did not return audio_url.' }] };
1370
+ }
1371
+
1372
+ const downloadRes = await fetch(remoteAudioUrl, {
1373
+ method: 'GET',
1374
+ headers: { 'Authorization': `Bearer ${MACHINE_API_KEY}` },
1375
+ });
1376
+ if (!downloadRes.ok) {
1377
+ return {
1378
+ isError: true,
1379
+ content: [{ type: 'text', text: `Failed to download synthesized audio (${downloadRes.status})` }],
1380
+ };
1381
+ }
1382
+
1383
+ const fileBuffer = Buffer.from(await downloadRes.arrayBuffer());
1384
+ mkdirSync(VOICEOVER_LOCAL_DIR, { recursive: true });
1385
+ const audioExt = inferAudioExtension(remoteAudioUrl, data.format ?? normalizedFormat);
1386
+ const localFileName = `voiceover-${Date.now()}-${randomUUID().slice(0, 8)}${audioExt}`;
1387
+ const localAudioPath = path.join(VOICEOVER_LOCAL_DIR, localFileName);
1388
+ writeFileSync(localAudioPath, fileBuffer);
1389
+
1390
+ return {
1391
+ content: [{
1392
+ type: 'text',
1393
+ text:
1394
+ `Voiceover generated.\n` +
1395
+ `workspace_id=${data.workspace_id ?? targetWorkspaceId}\n` +
1396
+ `local_audio_path=${localAudioPath}\n` +
1397
+ `duration_ms=${data.duration_ms ?? 'unknown'}\n` +
1398
+ `sample_rate=${data.sample_rate ?? 'unknown'}\n` +
1399
+ `format=${data.format ?? normalizedFormat}\n` +
1400
+ `size=${formatBytes(fileBuffer.length)}`,
1401
+ }],
1402
+ };
1403
+ }
1404
+ );
1405
+
1406
+ // ── compose_video ───────────────────────────────────────────────────────────────
1407
+ server.tool('compose_video',
1408
+ 'Compose a final short video by muxing audio onto a base video, optionally concatenating an outro, and transcoding to platform spec.',
1409
+ {
1410
+ video_path: z.string().describe('Base silent video path. Relative paths resolve from the current workspace.'),
1411
+ audio_segments: z.array(z.object({
1412
+ audio_path: z.string().describe('Audio file path for one narration segment.'),
1413
+ start_ms: z.union([z.number(), z.string()]).optional().describe('Segment start offset in milliseconds.'),
1414
+ phase: z.string().optional().describe('Optional phase id. Used with events_log to derive start time.'),
1415
+ })).describe('Ordered or unordered narration audio segments.'),
1416
+ events_log: z.array(z.any()).optional().describe('Optional recorder event log. Used to resolve segment start time by phase.'),
1417
+ outro_path: z.string().optional().describe('Optional outro mp4 path. If omitted, uses ~/.lightcone/assets/outros/default.mp4 when present.'),
1418
+ target: z.enum(['short_video_cn', 'douyin', 'xhs']).optional().describe('Transcode target profile. Defaults to short_video_cn.'),
1419
+ },
1420
+ async ({ video_path, audio_segments, events_log, outro_path, target }) => {
1421
+ const composeInput = { video_path, audio_segments, events_log, outro_path, target };
1422
+ try {
1423
+ const result = await runMandatoryLocalTool({
1424
+ toolName: 'compose_video',
1425
+ toolInput: composeInput,
1426
+ executor: async (checkedInput) => {
1427
+ const videoPath = normalizeComposePath(checkedInput.video_path, 'video_path');
1428
+ const audioSegments = normalizeComposeAudioSegments(checkedInput.audio_segments);
1429
+ const eventsLog = Array.isArray(checkedInput.events_log) ? checkedInput.events_log : [];
1430
+ const targetProfile = normalizeComposeTarget(checkedInput.target);
1431
+ const requestedOutroPath = String(checkedInput.outro_path ?? '').trim();
1432
+
1433
+ let resolvedOutroPath = null;
1434
+ if (requestedOutroPath) {
1435
+ resolvedOutroPath = path.resolve(WORKSPACE_DIR, requestedOutroPath);
1436
+ if (!existsSync(resolvedOutroPath)) {
1437
+ throw new Error(`outro_path not found: ${resolvedOutroPath}`);
1438
+ }
1439
+ } else if (existsSync(DEFAULT_OUTRO_PATH)) {
1440
+ resolvedOutroPath = DEFAULT_OUTRO_PATH;
1441
+ }
1442
+
1443
+ mkdirSync(VIDEO_COMPOSE_LOCAL_DIR, { recursive: true });
1444
+ const runId = `${Date.now()}-${randomUUID().slice(0, 8)}`;
1445
+ const muxedPath = path.join(VIDEO_COMPOSE_LOCAL_DIR, `compose-${runId}.muxed.mp4`);
1446
+ const concatPath = path.join(VIDEO_COMPOSE_LOCAL_DIR, `compose-${runId}.concat.mp4`);
1447
+ const finalPath = path.join(VIDEO_COMPOSE_LOCAL_DIR, `compose-${runId}.final.mp4`);
1448
+ const intermediates = [muxedPath, concatPath];
1449
+
1450
+ try {
1451
+ await muxAudioToVideo({
1452
+ video_path: videoPath,
1453
+ audio_segments: audioSegments,
1454
+ events_log: eventsLog,
1455
+ output: muxedPath,
1456
+ });
1457
+
1458
+ let composedPath = muxedPath;
1459
+ if (resolvedOutroPath) {
1460
+ await concatVideos({
1461
+ inputs: [muxedPath, resolvedOutroPath],
1462
+ output: concatPath,
1463
+ });
1464
+ composedPath = concatPath;
1465
+ }
1466
+
1467
+ await transcodeForPlatform({
1468
+ input: composedPath,
1469
+ output: finalPath,
1470
+ target: targetProfile,
1471
+ });
1472
+
1473
+ const durationMs = await probeDurationMs(finalPath);
1474
+ cleanupLocalFiles(intermediates);
1475
+ return {
1476
+ finalVideoPath: finalPath,
1477
+ durationMs,
1478
+ outroPath: resolvedOutroPath,
1479
+ target: targetProfile,
1480
+ };
1481
+ } catch (error) {
1482
+ cleanupLocalFiles([...intermediates, finalPath]);
1483
+ throw error;
1484
+ }
1485
+ },
1486
+ });
1487
+
1488
+ const outroText = result.outroPath ? result.outroPath : 'skipped';
1489
+ return {
1490
+ content: [{
1491
+ type: 'text',
1492
+ text:
1493
+ `Video composed.\n` +
1494
+ `final_video_path=${result.finalVideoPath}\n` +
1495
+ `duration_ms=${result.durationMs}\n` +
1496
+ `target=${result.target}\n` +
1497
+ `outro=${outroText}`,
1498
+ }],
1499
+ };
1500
+ } catch (error) {
1501
+ return { isError: true, content: [{ type: 'text', text: `Error: ${error.message}` }] };
1502
+ }
1503
+ }
1504
+ );
1505
+
1123
1506
  // ── register_data_source ───────────────────────────────────────────────────────
1124
1507
  server.tool('register_data_source',
1125
1508
  'Register a workspace data source without binding credential yet. Returns a one-time secure auth URL (10-minute expiry) that should be sent to the user via IM.',
@@ -1197,6 +1580,15 @@ server.tool('execute_approved_action',
1197
1580
  try {
1198
1581
  const data = await api('POST', `/actions/${action_id}/execute`, {});
1199
1582
  if (data.error) return { isError: true, content: [{ type: 'text', text: `Failed: ${data.error}` }] };
1583
+ if (data?.execution?.mode === 'user_daemon_job') {
1584
+ return { content: [{ type: 'text', text:
1585
+ `Action approved. Publish has been routed to a user-side daemon job.\n` +
1586
+ `actionType=${data.actionType} platform=${data.platform}\n` +
1587
+ `publish_job_id=${data.execution.publish_job_id} target_machine_id=${data.execution.target_machine_id}\n` +
1588
+ `status=${data.execution.status}\n` +
1589
+ `Do not call publish_content for this action_id again; wait for the daemon job result.`
1590
+ }]};
1591
+ }
1200
1592
  return { content: [{ type: 'text', text:
1201
1593
  `Action approved. Now call the appropriate platform tool with approval_action_id="${action_id}" to actually perform the operation.\n` +
1202
1594
  `actionType=${data.actionType} platform=${data.platform}\n` +
@@ -5,7 +5,7 @@ const t = (name) => `mcp__chat__${name}`;
5
5
  // stable so server-built and daemon-built system prompts can both target it.
6
6
  export const WORKSPACE_CONTEXT_PLACEHOLDER = '__WORKSPACE_CONTEXT_BLOCK__';
7
7
 
8
- const BASE_PROMPT = (displayName, name, description, agentId, feishuBotName) => `\
8
+ const BASE_PROMPT = (displayName, name, description, agentId, feishuBotName, workspaceDir = '', workspaceSharedDir = '') => `\
9
9
  You are "${displayName || name}", an AI agent in lightcone — a collaborative platform for human-AI collaboration.
10
10
  ${feishuBotName ? `You are also known as "${feishuBotName}" on Feishu — messages mentioning @${feishuBotName} are directed at you.\n` : ''}\
11
11
 
@@ -13,6 +13,13 @@ ${feishuBotName ? `You are also known as "${feishuBotName}" on Feishu — messag
13
13
 
14
14
  Your workspace and MEMORY.md persist across turns, so you can recover context when resumed. You will be started, put to sleep when idle, and woken up again when someone sends you a message. Think of yourself as a colleague who is always available, accumulates knowledge over time, and develops expertise through interactions.
15
15
 
16
+ ## Runtime paths
17
+
18
+ - Personal workspace absolute path: ${workspaceDir || '(not provided)'}
19
+ - Shared workspace absolute path: ${workspaceSharedDir || '(not provided)'}
20
+
21
+ Treat these as authoritative. Do not invent substitute roots such as /home/agent/workspace unless they exactly match the paths above.
22
+
16
23
  ${WORKSPACE_CONTEXT_PLACEHOLDER}
17
24
 
18
25
  ## Communication — MCP tools ONLY
@@ -350,9 +357,9 @@ export function buildSystemPrompt(config, agentId, skills) {
350
357
  if (typeof config?.systemPrompt === 'string' && config.systemPrompt.trim()) {
351
358
  return config.systemPrompt;
352
359
  }
353
- const { name, displayName, description, feishuBotName, rolePrompt } = config;
360
+ const { name, displayName, description, feishuBotName, rolePrompt, workspaceDir, workspaceSharedDir } = config;
354
361
 
355
- const base = BASE_PROMPT(displayName, name, description, agentId, feishuBotName);
362
+ const base = BASE_PROMPT(displayName, name, description, agentId, feishuBotName, workspaceDir, workspaceSharedDir);
356
363
 
357
364
  const roleSection = rolePrompt
358
365
  ? `\n\n## Your role in this workspace\n\n${rolePrompt}`
package/src/mcp-config.js CHANGED
@@ -69,6 +69,7 @@ function baseEnvForServer(serverKey, { serverUrl, authToken, agentId, workspaceI
69
69
  || serverKey === 'compliance-check'
70
70
  || serverKey === 'portfolio-read'
71
71
  || serverKey === 'portfolio-analysis'
72
+ || serverKey === 'video-narration-planner'
72
73
  ) {
73
74
  return {
74
75
  SERVER_URL: serverUrl,