@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,438 @@
1
+ import { existsSync, statSync, realpathSync, mkdirSync, writeFileSync } from 'fs';
2
+ import path from 'path';
3
+ import { getSession, closeSession } from '../mcp-servers/publisher/chrome-pool.js';
4
+ import { XhsAdapter } from '../mcp-servers/publisher/adapters/xhs.js';
5
+ import { DouyinAdapter } from '../mcp-servers/publisher/adapters/douyin.js';
6
+ import { KuaishouAdapter } from '../mcp-servers/publisher/adapters/kuaishou.js';
7
+ import { callOfficialTool } from '../mcp-servers/publisher/official-tool-client.js';
8
+ import { runPublishPrecheck } from '../mcp-servers/publisher/precheck.js';
9
+ import { withProfileLock } from './profile-lock.js';
10
+
11
+ const PLATFORM_ENV_KEYS = {
12
+ xhs: 'XHS_PROFILE_DIR',
13
+ douyin: 'DOUYIN_PROFILE_DIR',
14
+ kuaishou: 'KUAISHOU_PROFILE_DIR',
15
+ };
16
+
17
+ const ADAPTER_REGISTRY = Object.freeze({
18
+ xhs: XhsAdapter,
19
+ douyin: DouyinAdapter,
20
+ kuaishou: KuaishouAdapter,
21
+ });
22
+
23
+ const DEFAULT_MEDIA_LIMITS = {
24
+ xhs: { maxImages: 9, imageExts: ['.jpg', '.jpeg', '.png', '.webp'], videoExts: ['.mp4', '.mov'] },
25
+ douyin: { maxImages: 35, imageExts: ['.jpg', '.jpeg', '.png', '.webp'], videoExts: ['.mp4', '.mov', '.webm'] },
26
+ kuaishou: { maxImages: 12, imageExts: ['.jpg', '.jpeg', '.png'], videoExts: ['.mp4', '.mov'] },
27
+ };
28
+
29
+ function normalizeText(value) {
30
+ const normalized = String(value ?? '').trim();
31
+ return normalized || null;
32
+ }
33
+
34
+ function asObject(value) {
35
+ if (value && typeof value === 'object' && !Array.isArray(value)) return value;
36
+ return {};
37
+ }
38
+
39
+ function getProfileDir(platform) {
40
+ const key = PLATFORM_ENV_KEYS[platform];
41
+ if (!key) return null;
42
+ return process.env[key] ?? null;
43
+ }
44
+
45
+ function getAdapterClass(platform) {
46
+ const AdapterClass = ADAPTER_REGISTRY[platform];
47
+ if (!AdapterClass) throw new Error(`Unknown platform: ${platform}`);
48
+ return AdapterClass;
49
+ }
50
+
51
+ function createStaticAdapter(platform) {
52
+ const AdapterClass = getAdapterClass(platform);
53
+ return new AdapterClass(null);
54
+ }
55
+
56
+ async function getAdapter(platform) {
57
+ const profileDir = getProfileDir(platform);
58
+ if (!profileDir) {
59
+ throw new Error(`No profile dir for platform="${platform}". Has the user logged in and authorized this machine?`);
60
+ }
61
+ const cdp = await getSession(platform, profileDir);
62
+ const AdapterClass = getAdapterClass(platform);
63
+ return new AdapterClass(cdp);
64
+ }
65
+
66
+ async function withPublisherProfile(platform, fn) {
67
+ const profileDir = getProfileDir(platform);
68
+ if (!profileDir) {
69
+ throw new Error(`No profile dir for platform="${platform}". Has the user logged in and authorized this machine?`);
70
+ }
71
+ return withProfileLock(platform, profileDir, {
72
+ owner: `publisher:${platform}`,
73
+ timeoutMs: 30_000,
74
+ staleMs: 20 * 60 * 1000,
75
+ }, fn);
76
+ }
77
+
78
+ function isInsideDir(filePath, dir) {
79
+ const rel = path.relative(dir, filePath);
80
+ return rel === '' || (!!rel && !rel.startsWith('..') && !path.isAbsolute(rel));
81
+ }
82
+
83
+ function decodeWorkspaceContent(content) {
84
+ if (typeof content !== 'string') throw new Error('workspace file content is not a string');
85
+ if (!content.startsWith('data:')) return Buffer.from(content, 'utf8');
86
+
87
+ const commaIdx = content.indexOf(',');
88
+ if (commaIdx === -1) throw new Error('invalid data URL in workspace file');
89
+ const header = content.slice(5, commaIdx);
90
+ const body = content.slice(commaIdx + 1);
91
+ if (header.split(';').includes('base64')) return Buffer.from(body, 'base64');
92
+ return Buffer.from(decodeURIComponent(body), 'utf8');
93
+ }
94
+
95
+ async function internalApi({ serverUrl, machineApiKey, agentId, method, endpoint, body }) {
96
+ const url = `${String(serverUrl).replace(/\/$/, '')}/internal/agent/${encodeURIComponent(agentId)}${endpoint}`;
97
+ const res = await fetch(url, {
98
+ method,
99
+ headers: {
100
+ 'Content-Type': 'application/json',
101
+ Authorization: `Bearer ${machineApiKey}`,
102
+ },
103
+ body: body != null ? JSON.stringify(body) : undefined,
104
+ });
105
+ if (!res.ok) {
106
+ const text = await res.text();
107
+ throw new Error(`internal API ${method} ${endpoint} failed (${res.status}): ${text}`);
108
+ }
109
+ return res.json();
110
+ }
111
+
112
+ function workspacePathFromMediaPath(filePath, workspaceId) {
113
+ if (!filePath) return null;
114
+
115
+ const normalized = filePath.replaceAll('\\', '/');
116
+ const virtualMatch = normalized.match(/^\/agent-workspace\/([^/]+)\/workspace\/(.+)$/);
117
+ if (virtualMatch) return { workspaceId: virtualMatch[1], relPath: virtualMatch[2] };
118
+
119
+ const workspaceSegmentMatch = normalized.match(/\/workspace\/((?:artifacts|notes|tmp)\/.+)$/);
120
+ if (workspaceSegmentMatch) {
121
+ return { workspaceId, relPath: workspaceSegmentMatch[1] };
122
+ }
123
+
124
+ if (!path.isAbsolute(filePath) && /^(artifacts|notes|tmp)\//.test(normalized)) {
125
+ return { workspaceId, relPath: normalized };
126
+ }
127
+
128
+ return null;
129
+ }
130
+
131
+ async function materializeWorkspaceMedia({ filePath, workspaceId, workspaceRootDir, serverUrl, machineApiKey, agentId }) {
132
+ if (!filePath || existsSync(filePath)) return filePath;
133
+
134
+ const workspacePath = workspacePathFromMediaPath(filePath, workspaceId);
135
+ if (!workspacePath?.workspaceId || !workspacePath.relPath) return filePath;
136
+
137
+ const localPath = path.resolve(workspaceRootDir, workspacePath.relPath);
138
+ const allowedRoots = ['artifacts', 'notes', 'tmp'].map(dir => path.join(workspaceRootDir, dir));
139
+ if (!allowedRoots.some(root => isInsideDir(localPath, root))) {
140
+ throw new Error(`workspace media path is outside allowed workspace directories: ${filePath}`);
141
+ }
142
+
143
+ if (existsSync(localPath)) return localPath;
144
+
145
+ const data = await internalApi({
146
+ serverUrl,
147
+ machineApiKey,
148
+ agentId,
149
+ method: 'GET',
150
+ endpoint: `/workspace-memory?path=${encodeURIComponent(workspacePath.relPath)}&workspaceId=${encodeURIComponent(workspacePath.workspaceId)}`,
151
+ });
152
+ mkdirSync(path.dirname(localPath), { recursive: true });
153
+ writeFileSync(localPath, decodeWorkspaceContent(data.content));
154
+ return localPath;
155
+ }
156
+
157
+ async function materializeMedia({
158
+ images = [],
159
+ video,
160
+ cover,
161
+ workspaceId,
162
+ workspaceRootDir,
163
+ serverUrl,
164
+ machineApiKey,
165
+ agentId,
166
+ }) {
167
+ return {
168
+ images: await Promise.all(images.map(filePath => materializeWorkspaceMedia({
169
+ filePath,
170
+ workspaceId,
171
+ workspaceRootDir,
172
+ serverUrl,
173
+ machineApiKey,
174
+ agentId,
175
+ }))),
176
+ video: video ? await materializeWorkspaceMedia({
177
+ filePath: video,
178
+ workspaceId,
179
+ workspaceRootDir,
180
+ serverUrl,
181
+ machineApiKey,
182
+ agentId,
183
+ }) : video,
184
+ cover: cover ? await materializeWorkspaceMedia({
185
+ filePath: cover,
186
+ workspaceId,
187
+ workspaceRootDir,
188
+ serverUrl,
189
+ machineApiKey,
190
+ agentId,
191
+ }) : cover,
192
+ };
193
+ }
194
+
195
+ function validateLocalFile(filePath, { kind, required = false, allowedExts = [], workspaceDir, workspaceRootDir }) {
196
+ if (!filePath) {
197
+ if (required) throw new Error(`${kind} file path is required`);
198
+ return null;
199
+ }
200
+ if (!path.isAbsolute(filePath)) {
201
+ throw new Error(`${kind} path must be absolute: ${filePath}`);
202
+ }
203
+ if (!existsSync(filePath)) {
204
+ throw new Error(`${kind} file does not exist: ${filePath}`);
205
+ }
206
+
207
+ const realFile = realpathSync(filePath);
208
+ const allowedRoots = [
209
+ realpathSync(workspaceDir),
210
+ ...['artifacts', 'notes', 'tmp']
211
+ .map(dir => path.join(workspaceRootDir, dir))
212
+ .filter(existsSync)
213
+ .map(dir => realpathSync(dir)),
214
+ ];
215
+ if (!allowedRoots.some(root => isInsideDir(realFile, root))) {
216
+ throw new Error(`${kind} file must be inside the daemon workspace or shared artifacts/notes/tmp directories: ${filePath}`);
217
+ }
218
+
219
+ const stat = statSync(realFile);
220
+ if (!stat.isFile()) throw new Error(`${kind} path is not a file: ${filePath}`);
221
+ const ext = path.extname(realFile).toLowerCase();
222
+ if (allowedExts.length > 0 && !allowedExts.includes(ext)) {
223
+ throw new Error(`${kind} file has unsupported extension "${ext}". Allowed: ${allowedExts.join(', ')}`);
224
+ }
225
+ return realFile;
226
+ }
227
+
228
+ function normalizeFormatExts(formats = [], fallback = []) {
229
+ if (!Array.isArray(formats) || formats.length === 0) return fallback;
230
+ return formats
231
+ .map(item => String(item ?? '').trim().toLowerCase())
232
+ .filter(Boolean)
233
+ .map(item => (item.startsWith('.') ? item : `.${item}`));
234
+ }
235
+
236
+ function resolveMediaLimits(platform, contentType) {
237
+ const fallback = DEFAULT_MEDIA_LIMITS[platform] ?? DEFAULT_MEDIA_LIMITS.xhs;
238
+ const adapter = createStaticAdapter(platform);
239
+ const requirements = adapter.getRequirements(contentType) ?? {};
240
+ const capabilities = typeof adapter.getCapabilities === 'function'
241
+ ? (adapter.getCapabilities() ?? {})
242
+ : {};
243
+
244
+ return {
245
+ maxImages: Number(requirements.max_images ?? capabilities.max_image ?? fallback.maxImages),
246
+ imageExts: normalizeFormatExts(requirements.image_formats ?? capabilities.image_formats, fallback.imageExts),
247
+ videoExts: normalizeFormatExts(requirements.video_formats ?? capabilities.video_formats, fallback.videoExts),
248
+ };
249
+ }
250
+
251
+ function validateMedia({ platform, contentType, images = [], video, cover, workspaceDir, workspaceRootDir }) {
252
+ const limits = resolveMediaLimits(platform, contentType);
253
+ if (contentType === 'image_text') {
254
+ if (images.length === 0) {
255
+ throw new Error(`image_text requires at least 1 image on ${platform}.`);
256
+ }
257
+ if (images.length > limits.maxImages) {
258
+ throw new Error(`image_text supports at most ${limits.maxImages} images on ${platform}`);
259
+ }
260
+ return {
261
+ images: images.map(filePath => validateLocalFile(filePath, {
262
+ kind: 'image',
263
+ required: true,
264
+ allowedExts: limits.imageExts,
265
+ workspaceDir,
266
+ workspaceRootDir,
267
+ })),
268
+ video: null,
269
+ cover: null,
270
+ };
271
+ }
272
+
273
+ return {
274
+ images: [],
275
+ video: validateLocalFile(video, {
276
+ kind: 'video',
277
+ required: true,
278
+ allowedExts: limits.videoExts,
279
+ workspaceDir,
280
+ workspaceRootDir,
281
+ }),
282
+ cover: cover ? validateLocalFile(cover, {
283
+ kind: 'cover',
284
+ allowedExts: limits.imageExts,
285
+ workspaceDir,
286
+ workspaceRootDir,
287
+ }) : null,
288
+ };
289
+ }
290
+
291
+ function normalizeContentType(value) {
292
+ const normalized = String(value ?? '').trim().toLowerCase();
293
+ if (normalized === 'image_text' || normalized === 'short_video') return normalized;
294
+ return null;
295
+ }
296
+
297
+ function ensureStringArray(value) {
298
+ if (!Array.isArray(value)) return [];
299
+ return value.map(item => String(item ?? '')).filter(Boolean);
300
+ }
301
+
302
+ function buildJobInput(job = {}) {
303
+ const payload = asObject(job.payload);
304
+ const platform = String(job.platform ?? payload.platform ?? '').trim().toLowerCase();
305
+ const contentType = normalizeContentType(
306
+ payload.content_type ?? payload.contentType ?? job.content_type ?? job.contentType
307
+ );
308
+ const workspaceId = normalizeText(job.workspace_id ?? job.workspaceId ?? payload.workspace_id ?? payload.workspaceId);
309
+ const title = payload.title;
310
+ const text = payload.text;
311
+ const tags = ensureStringArray(payload.tags);
312
+ const images = ensureStringArray(payload.images);
313
+ const video = normalizeText(payload.video);
314
+ const cover = normalizeText(payload.cover);
315
+
316
+ return {
317
+ payload,
318
+ platform,
319
+ contentType,
320
+ workspaceId,
321
+ title,
322
+ text,
323
+ tags,
324
+ images,
325
+ video,
326
+ cover,
327
+ };
328
+ }
329
+
330
+ export async function runPublishJob({ serverUrl, machineApiKey, agentId, workspaceDir, job }) {
331
+ if (!serverUrl || !machineApiKey || !agentId) {
332
+ throw new Error('runPublishJob requires serverUrl, machineApiKey, and agentId');
333
+ }
334
+
335
+ const {
336
+ payload,
337
+ platform,
338
+ contentType,
339
+ workspaceId,
340
+ title,
341
+ text,
342
+ tags,
343
+ images,
344
+ video,
345
+ cover,
346
+ } = buildJobInput(job);
347
+
348
+ if (!platform) throw new Error('publish job missing platform');
349
+ if (!contentType) throw new Error('publish job missing content_type');
350
+ if (typeof text !== 'string' || !text.trim()) throw new Error('publish job missing text');
351
+
352
+ const resolvedWorkspaceDir = workspaceDir ?? process.cwd();
353
+ const workspaceRootDir = path.dirname(resolvedWorkspaceDir);
354
+
355
+ const precheck = await runPublishPrecheck({
356
+ platform,
357
+ title,
358
+ text,
359
+ tags,
360
+ payload,
361
+ callTool: callOfficialTool,
362
+ });
363
+ if (!precheck.ok) {
364
+ const blockerSummary = precheck.blockers.map(item => `${item.code}: ${item.message}`).join(' | ');
365
+ throw new Error(`PUBLISH_PRECHECK_BLOCKED:${blockerSummary || 'precheck failed'}`);
366
+ }
367
+
368
+ const localMedia = await materializeMedia({
369
+ images,
370
+ video,
371
+ cover,
372
+ workspaceId,
373
+ workspaceRootDir,
374
+ serverUrl,
375
+ machineApiKey,
376
+ agentId,
377
+ });
378
+ const staticAdapter = createStaticAdapter(platform);
379
+ const req = staticAdapter.getRequirements(contentType);
380
+ if (!req) throw new Error(`INPUT_UNSUPPORTED_CONTENT_TYPE: ${platform} does not support ${contentType}`);
381
+
382
+ const media = validateMedia({
383
+ platform,
384
+ contentType,
385
+ ...localMedia,
386
+ workspaceDir: resolvedWorkspaceDir,
387
+ workspaceRootDir,
388
+ });
389
+
390
+ try {
391
+ const { publishResult, healthCheck } = await withPublisherProfile(platform, async () => {
392
+ const adapter = await getAdapter(platform);
393
+ const prePublishLogin = await adapter.checkLoginStatus();
394
+ if (prePublishLogin?.loggedIn === false) {
395
+ throw new Error(`LOGIN_EXPIRED:${platform}`);
396
+ }
397
+ let publishResult = null;
398
+ if (contentType === 'image_text') {
399
+ publishResult = await adapter.publishImageText({ title, text, tags, images: media.images });
400
+ } else {
401
+ publishResult = await adapter.publishShortVideo({ title, text, tags, video: media.video, cover: media.cover });
402
+ }
403
+
404
+ let healthCheck = null;
405
+ try {
406
+ healthCheck = await adapter.checkLoginStatus();
407
+ } catch (error) {
408
+ healthCheck = {
409
+ loggedIn: null,
410
+ error: error.message,
411
+ };
412
+ }
413
+ return { publishResult, healthCheck };
414
+ });
415
+
416
+ return {
417
+ completionResult: {
418
+ precheck: {
419
+ blockers: precheck.blockers,
420
+ warnings: precheck.warnings,
421
+ policy_version: precheck?.policyScan?.policy_version ?? null,
422
+ advisory: precheck?.advisory?.advisory ?? null,
423
+ },
424
+ pre_publish_login: true,
425
+ publish_result: publishResult,
426
+ post_publish_health: healthCheck,
427
+ },
428
+ publishResult,
429
+ precheck,
430
+ healthCheck,
431
+ };
432
+ } catch (err) {
433
+ if (err.message?.startsWith('LOGIN_EXPIRED')) {
434
+ closeSession(platform);
435
+ }
436
+ throw err;
437
+ }
438
+ }
@@ -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
+ }