@lightcone-ai/daemon 0.13.0 → 0.14.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.
@@ -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,9 @@ 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',
200
+ register_data_source: 'mandatory',
118
201
  bind_workspace_scenario: 'mandatory',
119
202
  create_workspace: 'mandatory',
120
203
  rename_workspace: 'mandatory',
@@ -170,6 +253,7 @@ const CACHE_INVALIDATION_TOOLS = new Set([
170
253
  'update_goal_field',
171
254
  'supersede_goal_field',
172
255
  'request_credential_auth',
256
+ 'register_data_source',
173
257
  'bind_workspace_scenario',
174
258
  'create_workspace',
175
259
  'rename_workspace',
@@ -246,6 +330,8 @@ function inferToolForApi(method, apiPath, body) {
246
330
  if (method === 'POST' && cleanPath === '/goal-fields/update') return 'update_goal_field';
247
331
  if (method === 'POST' && cleanPath === '/goal-fields/supersede') return 'supersede_goal_field';
248
332
  if (method === 'POST' && cleanPath === '/credential-auth/request') return 'request_credential_auth';
333
+ if (method === 'POST' && cleanPath === '/tts/voiceover') return 'generate_voiceover';
334
+ if (method === 'POST' && cleanPath === '/api/data-sources') return 'register_data_source';
249
335
  if (method === 'POST' && cleanPath === '/orchestrate/decision') return 'write_governance_decision';
250
336
  if (method === 'POST' && cleanPath === '/orchestrate/correction') return 'write_governance_correction';
251
337
  if (method === 'GET' && cleanPath === '/orchestrate/context') return 'get_orchestrate_context';
@@ -417,6 +503,32 @@ async function directApi(method, apiPath, body) {
417
503
  return res.json();
418
504
  }
419
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
+
420
532
  async function callGovernance(payload, { retry = true } = {}) {
421
533
  const attempts = retry ? 2 : 1;
422
534
  let lastError = null;
@@ -504,6 +616,61 @@ async function governanceRoundTrip({ method, apiPath, body, toolName, classifica
504
616
  return directApi(method, apiPath, nextBody);
505
617
  }
506
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
+
507
674
  function renewCacheInBackground({ method, apiPath, body, toolName, cacheKey }) {
508
675
  if (governanceContext.renewalInFlight.has(cacheKey)) return;
509
676
  governanceContext.renewalInFlight.add(cacheKey);
@@ -921,12 +1088,54 @@ server.tool('write_workspace_file', 'Write a local file directly to the shared w
921
1088
  }, async ({ file_path, path }) => {
922
1089
  if (!currentWorkspaceId) return { content: [{ type: 'text', text: 'No workspace context.' }] };
923
1090
  const localPath = resolveLocalWorkspaceFile(file_path);
924
- const ext = extname(path || localPath).toLowerCase();
925
- const mime = WORKSPACE_BINARY_MIME[ext] ?? 'application/octet-stream';
926
- const buf = readFileSync(localPath);
927
- const content = `data:${mime};base64,${buf.toString('base64')}`;
928
- await api('PUT', `/workspace-memory?path=${encodeURIComponent(path)}&workspaceId=${encodeURIComponent(currentWorkspaceId)}`, { content });
929
- 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
+ };
930
1139
  });
931
1140
 
932
1141
  // ── skill_list ───────────────────────────────────────────────────────────────
@@ -1117,6 +1326,222 @@ server.tool('request_credential_auth',
1117
1326
  }
1118
1327
  );
1119
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
+
1506
+ // ── register_data_source ───────────────────────────────────────────────────────
1507
+ server.tool('register_data_source',
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.',
1509
+ {
1510
+ workspace_id: z.string().optional().describe('Target workspace id. Defaults to current workspace context if omitted.'),
1511
+ display_name: z.string().describe('Human-readable data source name.'),
1512
+ source_type: z.string().describe('Data source type, e.g. "mysql", "postgresql", "api", "csv", "rss", "google_sheets".'),
1513
+ schema_hint: z.any().optional().describe('Optional schema hint JSON for downstream query planning.'),
1514
+ },
1515
+ async ({ workspace_id, display_name, source_type, schema_hint }) => {
1516
+ const targetWorkspaceId = (workspace_id ?? currentWorkspaceId ?? WORKSPACE_ID ?? '').trim();
1517
+ if (!targetWorkspaceId) {
1518
+ return { isError: true, content: [{ type: 'text', text: 'workspace_id is required (no current workspace context).' }] };
1519
+ }
1520
+
1521
+ const body = {
1522
+ workspace_id: targetWorkspaceId,
1523
+ display_name,
1524
+ source_type,
1525
+ };
1526
+ if (schema_hint !== undefined) body.schema_hint = schema_hint;
1527
+
1528
+ const data = await api('POST', '/api/data-sources', body);
1529
+ return {
1530
+ content: [{
1531
+ type: 'text',
1532
+ text:
1533
+ `Data source registered.\n` +
1534
+ `workspace_id=${data.workspace_id ?? targetWorkspaceId}\n` +
1535
+ `data_source_id=${data.data_source_id}\n` +
1536
+ `source_type=${data.source_type ?? source_type}\n` +
1537
+ `expires_at=${data.expires_at ?? 'unknown'}\n` +
1538
+ `secure_auth_url=${data.secure_auth_url}\n\n` +
1539
+ `Send secure_auth_url to the user in IM so they can complete credential binding securely.`,
1540
+ }],
1541
+ };
1542
+ }
1543
+ );
1544
+
1120
1545
  // ── request_approval ──────────────────────────────────────────────────────────
1121
1546
  server.tool('request_approval',
1122
1547
  'Request human approval before executing a sensitive platform action (posting, sending, publishing). Returns an action_id. After the human approves, call execute_approved_action with that ID.',
@@ -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
@@ -58,7 +58,8 @@ function baseEnvForServer(serverKey, { serverUrl, authToken, agentId, workspaceI
58
58
  return { SERVER_URL: serverUrl, MACHINE_API_KEY: authToken, AGENT_ID: agentId };
59
59
  }
60
60
  if (
61
- serverKey === 'publisher'
61
+ serverKey === 'mysql'
62
+ || serverKey === 'publisher'
62
63
  || serverKey === 'platform'
63
64
  || serverKey === 'research-fetch'
64
65
  || serverKey === 'market-data-query'
@@ -68,6 +69,7 @@ function baseEnvForServer(serverKey, { serverUrl, authToken, agentId, workspaceI
68
69
  || serverKey === 'compliance-check'
69
70
  || serverKey === 'portfolio-read'
70
71
  || serverKey === 'portfolio-analysis'
72
+ || serverKey === 'video-narration-planner'
71
73
  ) {
72
74
  return {
73
75
  SERVER_URL: serverUrl,
@@ -0,0 +1,71 @@
1
+ import path, { extname } from 'node:path';
2
+ import { readFileSync } from 'node:fs';
3
+
4
+ export const WORKSPACE_BINARY_MIME = {
5
+ '.png': 'image/png',
6
+ '.jpg': 'image/jpeg',
7
+ '.jpeg': 'image/jpeg',
8
+ '.webp': 'image/webp',
9
+ '.gif': 'image/gif',
10
+ '.pdf': 'application/pdf',
11
+ };
12
+
13
+ export const VIDEO_EXT = '.mp4';
14
+ export const VIDEO_MIME = 'video/mp4';
15
+
16
+ export function resolveWorkspaceFileUploadPlan({ localPath, workspacePath }) {
17
+ const target = String(workspacePath ?? '').trim() || String(localPath ?? '').trim();
18
+ const ext = extname(target).toLowerCase();
19
+ const filename = path.basename(target || localPath);
20
+ if (ext === VIDEO_EXT) {
21
+ return {
22
+ mode: 'upload-video',
23
+ ext,
24
+ filename,
25
+ mime: VIDEO_MIME,
26
+ };
27
+ }
28
+ return {
29
+ mode: 'workspace-memory',
30
+ ext,
31
+ filename,
32
+ mime: WORKSPACE_BINARY_MIME[ext] ?? 'application/octet-stream',
33
+ };
34
+ }
35
+
36
+ export async function writeLocalFileToWorkspace({
37
+ localPath,
38
+ workspacePath,
39
+ workspaceId,
40
+ readFileSyncFn = readFileSync,
41
+ uploadWorkspaceMemory,
42
+ uploadVideo,
43
+ }) {
44
+ const plan = resolveWorkspaceFileUploadPlan({ localPath, workspacePath });
45
+ if (plan.mode === 'upload-video') {
46
+ return uploadVideo({
47
+ localPath,
48
+ workspacePath,
49
+ workspaceId,
50
+ filename: plan.filename,
51
+ mime: plan.mime,
52
+ mode: plan.mode,
53
+ });
54
+ }
55
+
56
+ const buf = readFileSyncFn(localPath);
57
+ const content = `data:${plan.mime};base64,${buf.toString('base64')}`;
58
+ await uploadWorkspaceMemory({
59
+ workspacePath,
60
+ workspaceId,
61
+ content,
62
+ mime: plan.mime,
63
+ bytes: buf.length,
64
+ mode: plan.mode,
65
+ });
66
+ return {
67
+ mode: plan.mode,
68
+ mime: plan.mime,
69
+ bytes: buf.length,
70
+ };
71
+ }