@lightcone-ai/daemon 0.15.74 → 0.15.76
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.
- package/mcp-servers/official/media-tools/index.js +68 -0
- package/mcp-servers/official/media-tools/lib/presets.js +62 -0
- package/mcp-servers/official/media-tools/lib/render.js +171 -0
- package/mcp-servers/official/media-tools/manifest.json +19 -0
- package/package.json +1 -1
- package/src/chat-bridge.js +7 -3
- package/src/lifecycle-protocol.js +13 -2
- package/src/submit-to-library-tool.js +15 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
|
|
6
|
+
import { addTitleEffects } from './lib/render.js';
|
|
7
|
+
import { SUPPORTED_PRESETS } from './lib/presets.js';
|
|
8
|
+
|
|
9
|
+
const PRESET_ENUM = z.enum(SUPPORTED_PRESETS);
|
|
10
|
+
const POSITION_ENUM = z.enum(['top', 'center', 'bottom']);
|
|
11
|
+
|
|
12
|
+
const overlaySchema = z.object({
|
|
13
|
+
text: z.string().min(1).describe('Title text to overlay. Single line or use \\n for multi-line.'),
|
|
14
|
+
start_ms: z.number().int().nonnegative().describe('Overlay start time in milliseconds from video start.'),
|
|
15
|
+
end_ms: z.number().int().positive().describe('Overlay end time in milliseconds. Must be > start_ms.'),
|
|
16
|
+
preset: PRESET_ENUM.describe(`Animation preset: ${SUPPORTED_PRESETS.join(' | ')}. fade_zoom = fade in + scale-up pop (good for opening titles). karaoke_punch = per-character highlight fill (good for rhythmic, beat-driven titles).`),
|
|
17
|
+
position: POSITION_ENUM.optional().describe('Vertical position. Default top — leaves the bottom band free for the narration subtitle already burned by compose_video_v2.'),
|
|
18
|
+
style: z.object({
|
|
19
|
+
font_size: z.number().int().positive().optional().describe('Override font size in pixels. Default 96 (relative to source resolution).'),
|
|
20
|
+
color: z.string().optional().describe('Text fill colour as #RRGGBB. Default white.'),
|
|
21
|
+
outline_color: z.string().optional().describe('Outline colour as #RRGGBB. Default black.'),
|
|
22
|
+
bold: z.boolean().optional().describe('Bold. Default true.'),
|
|
23
|
+
}).optional(),
|
|
24
|
+
}).strict();
|
|
25
|
+
|
|
26
|
+
function toolError(message) {
|
|
27
|
+
return {
|
|
28
|
+
isError: true,
|
|
29
|
+
content: [{ type: 'text', text: `Error: ${message}` }],
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const server = new McpServer({ name: 'official-media-tools', version: '0.1.0' });
|
|
34
|
+
|
|
35
|
+
server.tool(
|
|
36
|
+
'add_title_effects',
|
|
37
|
+
'Burn animated title overlays into the START (or any time range) of an existing mp4. '
|
|
38
|
+
+ 'Takes any video produced by compose_video_v2 / record_url_narration / etc. and adds '
|
|
39
|
+
+ 'one or more on-screen title cards with animation presets (fade+zoom pop, per-character karaoke fill). '
|
|
40
|
+
+ 'The default narration subtitle burned by compose_video_v2 stays at the bottom; titles default to the top band so they do not collide. '
|
|
41
|
+
+ 'Output is a new mp4; original is not modified. Skip this tool entirely when a plain video is desired — not every video needs title effects.',
|
|
42
|
+
{
|
|
43
|
+
input_path: z.string().min(1).describe('Absolute path to the source mp4 (e.g. the output of compose_video_v2).'),
|
|
44
|
+
output_path: z.string().optional().describe('Optional absolute output path. If omitted, writes to a tmp path and returns it.'),
|
|
45
|
+
overlays: z.array(overlaySchema).min(1).describe('One or more title overlays. For a single opening title use overlays=[{text, start_ms:0, end_ms:2500, preset:"fade_zoom"}].'),
|
|
46
|
+
},
|
|
47
|
+
async (input) => {
|
|
48
|
+
try {
|
|
49
|
+
const result = await addTitleEffects({
|
|
50
|
+
inputPath: input.input_path,
|
|
51
|
+
outputPath: input.output_path,
|
|
52
|
+
overlays: input.overlays,
|
|
53
|
+
});
|
|
54
|
+
const lines = [
|
|
55
|
+
'add_title_effects completed.',
|
|
56
|
+
`path=${result.path}`,
|
|
57
|
+
`overlays=${result.overlays}`,
|
|
58
|
+
];
|
|
59
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
60
|
+
} catch (error) {
|
|
61
|
+
return toolError(`add_title_effects failed: ${error.message}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const transport = new StdioServerTransport();
|
|
67
|
+
await server.connect(transport);
|
|
68
|
+
console.error('[official-media-tools] MCP Server started');
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Animation presets emit ASS inline override tags. Each preset receives the
|
|
2
|
+
// overlay duration (ms) and returns:
|
|
3
|
+
// { prefix: string, body: (text) => string }
|
|
4
|
+
// where the final Dialogue Text becomes `${prefix}${body(text)}`.
|
|
5
|
+
//
|
|
6
|
+
// Notes on units:
|
|
7
|
+
// - \fad takes ms
|
|
8
|
+
// - \t takes ms (since libass interprets \t(t1,t2,...) as ms when used with
|
|
9
|
+
// time-based override tags)
|
|
10
|
+
// - \k / \kf take centiseconds (1cs = 10ms)
|
|
11
|
+
|
|
12
|
+
const SUPPORTED_PRESETS = Object.freeze(['fade_zoom', 'karaoke_punch']);
|
|
13
|
+
|
|
14
|
+
function fadeZoom() {
|
|
15
|
+
// Fade in 250ms + fade out 250ms; scale from 100% to 130% over the first
|
|
16
|
+
// 500ms so the title "pops" before settling. Override tags applied once at
|
|
17
|
+
// the start of the line.
|
|
18
|
+
const prefix = '{\\fad(250,250)\\t(0,500,\\fscx130\\fscy130)}';
|
|
19
|
+
return { prefix, body: text => text };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function karaokePunch(durationMs) {
|
|
23
|
+
// Per-character karaoke fill: each char briefly highlights to the secondary
|
|
24
|
+
// colour. Distribute time evenly. Karaoke timings are in centiseconds and
|
|
25
|
+
// the sum must match the event duration; we round and adjust the last char
|
|
26
|
+
// so the total is exact (libass tolerates any remainder by clipping).
|
|
27
|
+
const totalCs = Math.max(1, Math.round(durationMs / 10));
|
|
28
|
+
// Reserve a small tail (~20cs / 200ms) so the last character finishes its
|
|
29
|
+
// fill before the event ends rather than mid-stroke.
|
|
30
|
+
const tailCs = Math.min(20, Math.max(0, totalCs - 1));
|
|
31
|
+
const animCs = totalCs - tailCs;
|
|
32
|
+
return {
|
|
33
|
+
prefix: '',
|
|
34
|
+
body: text => {
|
|
35
|
+
const chars = Array.from(String(text ?? ''));
|
|
36
|
+
if (chars.length === 0) return '';
|
|
37
|
+
const perChar = Math.max(1, Math.floor(animCs / chars.length));
|
|
38
|
+
const usedExceptLast = perChar * (chars.length - 1);
|
|
39
|
+
const lastCs = Math.max(1, animCs - usedExceptLast);
|
|
40
|
+
return chars.map((ch, i) => {
|
|
41
|
+
const cs = i === chars.length - 1 ? lastCs : perChar;
|
|
42
|
+
// Escape ASS comma in case the char itself is a comma (rare but
|
|
43
|
+
// shifts dialogue fields when unescaped).
|
|
44
|
+
const safe = ch === ',' ? '{\\,}' : ch;
|
|
45
|
+
return `{\\kf${cs}}${safe}`;
|
|
46
|
+
}).join('');
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function renderPreset(presetName, { durationMs }) {
|
|
52
|
+
switch (presetName) {
|
|
53
|
+
case 'fade_zoom':
|
|
54
|
+
return fadeZoom();
|
|
55
|
+
case 'karaoke_punch':
|
|
56
|
+
return karaokePunch(durationMs);
|
|
57
|
+
default:
|
|
58
|
+
throw new Error(`unknown preset: ${presetName}. supported: ${SUPPORTED_PRESETS.join(', ')}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export { SUPPORTED_PRESETS };
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { mkdir, writeFile, access, rm } from 'node:fs/promises';
|
|
5
|
+
import { constants as fsConstants } from 'node:fs';
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
|
|
8
|
+
import { renderPreset, SUPPORTED_PRESETS } from './presets.js';
|
|
9
|
+
|
|
10
|
+
const DEFAULT_FONT = (process.env.SUBTITLE_FONT || 'Noto Sans CJK SC').split(',')[0].trim() || 'Noto Sans CJK SC';
|
|
11
|
+
const POSITION_ALIGNMENTS = Object.freeze({ top: 8, center: 5, bottom: 2 });
|
|
12
|
+
const POSITION_MARGINS = Object.freeze({ top: 200, center: 0, bottom: 200 });
|
|
13
|
+
|
|
14
|
+
function msToAssTimestamp(ms) {
|
|
15
|
+
const totalCs = Math.round(Math.max(0, ms) / 10);
|
|
16
|
+
const cs = totalCs % 100;
|
|
17
|
+
const totalSec = Math.floor(totalCs / 100);
|
|
18
|
+
const sec = totalSec % 60;
|
|
19
|
+
const min = Math.floor(totalSec / 60) % 60;
|
|
20
|
+
const hr = Math.floor(totalSec / 3600);
|
|
21
|
+
return `${hr}:${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}.${String(cs).padStart(2, '0')}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function runFfmpeg(args, label = 'ffmpeg') {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
const proc = spawn('ffmpeg', ['-y', ...args], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
27
|
+
const stderr = [];
|
|
28
|
+
proc.stderr.on('data', chunk => stderr.push(chunk));
|
|
29
|
+
proc.on('close', code => {
|
|
30
|
+
if (code === 0) return resolve();
|
|
31
|
+
const msg = Buffer.concat(stderr).toString().slice(-3000);
|
|
32
|
+
reject(new Error(`${label} exited ${code}:\n${msg}`));
|
|
33
|
+
});
|
|
34
|
+
proc.on('error', err => reject(new Error(`${label} spawn failed: ${err.message}`)));
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function probeVideoSize(inputPath) {
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
const proc = spawn('ffprobe', [
|
|
41
|
+
'-v', 'error',
|
|
42
|
+
'-select_streams', 'v:0',
|
|
43
|
+
'-show_entries', 'stream=width,height',
|
|
44
|
+
'-of', 'csv=p=0',
|
|
45
|
+
inputPath,
|
|
46
|
+
], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
47
|
+
const out = [];
|
|
48
|
+
proc.stdout.on('data', c => out.push(c));
|
|
49
|
+
proc.on('close', code => {
|
|
50
|
+
if (code !== 0) return reject(new Error(`ffprobe failed on ${inputPath}`));
|
|
51
|
+
const text = Buffer.concat(out).toString().trim();
|
|
52
|
+
const [wStr, hStr] = text.split(',');
|
|
53
|
+
const width = parseInt(wStr, 10);
|
|
54
|
+
const height = parseInt(hStr, 10);
|
|
55
|
+
if (!Number.isFinite(width) || !Number.isFinite(height)) {
|
|
56
|
+
return reject(new Error(`ffprobe returned unexpected size: ${text}`));
|
|
57
|
+
}
|
|
58
|
+
resolve({ width, height });
|
|
59
|
+
});
|
|
60
|
+
proc.on('error', reject);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function fileExists(p) {
|
|
65
|
+
try { await access(p, fsConstants.R_OK); return true; } catch { return false; }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Hex color in ASS is &HAABBGGRR (alpha + BGR). Accept input as #RRGGBB or
|
|
69
|
+
// 0xRRGGBB and convert.
|
|
70
|
+
function hexToAssColor(hex, alpha = 0) {
|
|
71
|
+
const raw = String(hex ?? '').trim().replace(/^#/, '').replace(/^0x/i, '');
|
|
72
|
+
if (!/^[0-9a-fA-F]{6}$/.test(raw)) return null;
|
|
73
|
+
const r = raw.slice(0, 2).toUpperCase();
|
|
74
|
+
const g = raw.slice(2, 4).toUpperCase();
|
|
75
|
+
const b = raw.slice(4, 6).toUpperCase();
|
|
76
|
+
const a = String(Math.max(0, Math.min(255, Math.round(alpha))).toString(16).padStart(2, '0')).toUpperCase();
|
|
77
|
+
return `&H${a}${b}${g}${r}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function buildAssContent({ playResX, playResY, overlays }) {
|
|
81
|
+
const header = [
|
|
82
|
+
'[Script Info]',
|
|
83
|
+
'ScriptType: v4.00+',
|
|
84
|
+
`PlayResX: ${playResX}`,
|
|
85
|
+
`PlayResY: ${playResY}`,
|
|
86
|
+
'WrapStyle: 2',
|
|
87
|
+
'',
|
|
88
|
+
'[V4+ Styles]',
|
|
89
|
+
'Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding',
|
|
90
|
+
// PrimaryColour white, SecondaryColour orange (for karaoke fill), OutlineColour black,
|
|
91
|
+
// Bold on, Outline 4px, Shadow 2px, default Alignment middle-center (5) — events
|
|
92
|
+
// override per-line via \an.
|
|
93
|
+
`Style: Title,${DEFAULT_FONT},96,&H00FFFFFF,&H000066FF,&H00000000,&H80000000,-1,0,0,0,100,100,0,0,1,4,2,5,30,30,0,1`,
|
|
94
|
+
'',
|
|
95
|
+
'[Events]',
|
|
96
|
+
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
const events = overlays.map((overlay) => {
|
|
100
|
+
const start = overlay.start_ms;
|
|
101
|
+
const end = overlay.end_ms;
|
|
102
|
+
const alignment = POSITION_ALIGNMENTS[overlay.position] ?? POSITION_ALIGNMENTS.top;
|
|
103
|
+
const marginV = POSITION_MARGINS[overlay.position] ?? POSITION_MARGINS.top;
|
|
104
|
+
|
|
105
|
+
const styleOverrides = [`\\an${alignment}`];
|
|
106
|
+
if (overlay.style?.font_size) styleOverrides.push(`\\fs${overlay.style.font_size}`);
|
|
107
|
+
if (overlay.style?.color) {
|
|
108
|
+
const c = hexToAssColor(overlay.style.color);
|
|
109
|
+
if (c) styleOverrides.push(`\\1c${c}`);
|
|
110
|
+
}
|
|
111
|
+
if (overlay.style?.outline_color) {
|
|
112
|
+
const c = hexToAssColor(overlay.style.outline_color);
|
|
113
|
+
if (c) styleOverrides.push(`\\3c${c}`);
|
|
114
|
+
}
|
|
115
|
+
if (overlay.style?.bold === false) styleOverrides.push('\\b0');
|
|
116
|
+
|
|
117
|
+
const preset = renderPreset(overlay.preset, { durationMs: end - start });
|
|
118
|
+
const base = `{${styleOverrides.join('')}${preset.prefix.replace(/^\{|\}$/g, '')}}`;
|
|
119
|
+
const text = preset.body(overlay.text).replace(/\r?\n/g, '\\N');
|
|
120
|
+
return `Dialogue: 0,${msToAssTimestamp(start)},${msToAssTimestamp(end)},Title,,0,0,${marginV},,${base}${text}`;
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
return `${header.join('\n')}\n${events.join('\n')}\n`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function addTitleEffects({ inputPath, outputPath, overlays }) {
|
|
127
|
+
if (!await fileExists(inputPath)) {
|
|
128
|
+
throw new Error(`input_path not found or unreadable: ${inputPath}`);
|
|
129
|
+
}
|
|
130
|
+
if (!Array.isArray(overlays) || overlays.length === 0) {
|
|
131
|
+
throw new Error('overlays must be a non-empty array');
|
|
132
|
+
}
|
|
133
|
+
for (let i = 0; i < overlays.length; i++) {
|
|
134
|
+
const o = overlays[i];
|
|
135
|
+
if (typeof o.text !== 'string' || !o.text.trim()) {
|
|
136
|
+
throw new Error(`overlays[${i}]: text must be a non-empty string`);
|
|
137
|
+
}
|
|
138
|
+
if (!Number.isFinite(o.start_ms) || !Number.isFinite(o.end_ms) || o.end_ms <= o.start_ms) {
|
|
139
|
+
throw new Error(`overlays[${i}]: start_ms/end_ms must be finite and end_ms > start_ms`);
|
|
140
|
+
}
|
|
141
|
+
if (!SUPPORTED_PRESETS.includes(o.preset)) {
|
|
142
|
+
throw new Error(`overlays[${i}]: preset must be one of ${SUPPORTED_PRESETS.join(', ')}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const { width, height } = await probeVideoSize(inputPath);
|
|
147
|
+
|
|
148
|
+
const tmpDir = path.join(os.tmpdir(), `media-tools-${randomUUID().slice(0, 8)}`);
|
|
149
|
+
await mkdir(tmpDir, { recursive: true });
|
|
150
|
+
|
|
151
|
+
const outPath = outputPath ?? path.join(os.tmpdir(), `media-tools-${Date.now()}-titled.mp4`);
|
|
152
|
+
await mkdir(path.dirname(outPath), { recursive: true });
|
|
153
|
+
|
|
154
|
+
const assPath = path.join(tmpDir, `title-${randomUUID().slice(0, 8)}.ass`);
|
|
155
|
+
const assContent = buildAssContent({ playResX: width, playResY: height, overlays });
|
|
156
|
+
await writeFile(assPath, assContent, 'utf8');
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
const escapedAssPath = assPath.replace(/\\/g, '/').replace(/:/g, '\\:').replace(/'/g, "\\'");
|
|
160
|
+
await runFfmpeg([
|
|
161
|
+
'-i', inputPath,
|
|
162
|
+
'-vf', `subtitles='${escapedAssPath}'`,
|
|
163
|
+
'-c:a', 'copy',
|
|
164
|
+
'-movflags', '+faststart',
|
|
165
|
+
outPath,
|
|
166
|
+
], 'ffmpeg add-title-effects');
|
|
167
|
+
return { path: outPath, overlays: overlays.length };
|
|
168
|
+
} finally {
|
|
169
|
+
await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "media-tools",
|
|
3
|
+
"name": "Official Media Tools MCP",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"runtime": "node",
|
|
6
|
+
"entrypoint": "index.js",
|
|
7
|
+
"tool_declarations": [
|
|
8
|
+
{ "name": "add_title_effects", "classification": "cacheable" }
|
|
9
|
+
],
|
|
10
|
+
"smoke_test": {
|
|
11
|
+
"tool": "add_title_effects",
|
|
12
|
+
"arguments": {
|
|
13
|
+
"input_path": "/tmp/__media_tools_smoke_does_not_exist.mp4",
|
|
14
|
+
"overlays": [
|
|
15
|
+
{ "text": "标题特效", "start_ms": 0, "end_ms": 2000, "preset": "fade_zoom" }
|
|
16
|
+
]
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
package/package.json
CHANGED
package/src/chat-bridge.js
CHANGED
|
@@ -1523,7 +1523,7 @@ server.tool('register_data_source',
|
|
|
1523
1523
|
|
|
1524
1524
|
// ── list_publish_accounts ─────────────────────────────────────────────────────
|
|
1525
1525
|
server.tool('list_publish_accounts',
|
|
1526
|
-
'List the platform accounts bound to a workspace and
|
|
1526
|
+
'List the platform accounts bound to a workspace. Each entry returns: account_id (workspace-account UUID), credential_id (REAL credential UUID — use this for credential-keyed tools like check_login_status / request_credential_auth), display_name, account_role, and selectors. The "selectors" list shows extra strings that request_approval will accept as credential_id (display name / role alias 主号·矩阵号·测试号) — for request_approval only; do NOT pass selectors or account_id to credential-keyed tools. Call this before publishing whenever the workspace might have more than one account on the target platform — never guess account names.',
|
|
1527
1527
|
{
|
|
1528
1528
|
workspace: z.string().optional().describe('Target #workspace-name. Defaults to the current workspace.'),
|
|
1529
1529
|
platform: z.string().optional().describe('Optional platform filter, e.g. "xhs", "kuaishou", "douyin", "bilibili".'),
|
|
@@ -1544,10 +1544,14 @@ server.tool('list_publish_accounts',
|
|
|
1544
1544
|
const text = accounts.map((a) => {
|
|
1545
1545
|
const name = a.display_name ?? a.account_id ?? 'unknown';
|
|
1546
1546
|
const role = a.account_role ? ` [${a.account_role}]` : '';
|
|
1547
|
+
const credLine = a.credential_id
|
|
1548
|
+
? `\n credential_id (for check_login_status etc.): ${a.credential_id}`
|
|
1549
|
+
: '\n credential_id: <unbound>';
|
|
1550
|
+
const accLine = `\n account_id: ${a.account_id ?? '?'}`;
|
|
1547
1551
|
const sels = Array.isArray(a.selectors) && a.selectors.length
|
|
1548
|
-
?
|
|
1552
|
+
? `\n request_approval credential_id can also be: ${a.selectors.join(' / ')}`
|
|
1549
1553
|
: '';
|
|
1550
|
-
return `- ${a.platform ?? '?'} / ${name}${role}${sels}`;
|
|
1554
|
+
return `- ${a.platform ?? '?'} / ${name}${role}${credLine}${accLine}${sels}`;
|
|
1551
1555
|
}).join('\n');
|
|
1552
1556
|
return { content: [{ type: 'text', text }] };
|
|
1553
1557
|
} catch (err) {
|
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
// Convention: when a process catches SIGTERM and calls process.exit(143) /
|
|
2
|
+
// process.exit(130), Node reports `code=143/130, signal=null` instead of
|
|
3
|
+
// `code=null, signal='SIGTERM'`. Treat both forms identically — it's a clean
|
|
4
|
+
// stop (used by AgentManager.restartAgent for skill-binding restarts), NOT a
|
|
5
|
+
// crash. Without this, every skill bind made the UI flash "agent crashed".
|
|
6
|
+
function isTerminationExit(code, signal) {
|
|
7
|
+
if (signal === 'SIGTERM' || signal === 'SIGINT') return true;
|
|
8
|
+
if (code === 143 || code === 130) return true;
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
|
|
1
12
|
export function resolveExitOfflineDetail({ code, signal, stopCause }) {
|
|
2
13
|
if (stopCause === 'manual_stop') return '';
|
|
3
14
|
if (stopCause === 'user_cancelled') return '';
|
|
@@ -5,7 +16,7 @@ export function resolveExitOfflineDetail({ code, signal, stopCause }) {
|
|
|
5
16
|
if (stopCause === 'contract_violation') return 'contract_violation:visible_text_without_send_message';
|
|
6
17
|
if (stopCause === 'outbound_rate_limited') return 'outbound_rate_limited';
|
|
7
18
|
if (code === 0) return '';
|
|
8
|
-
if (signal
|
|
19
|
+
if (isTerminationExit(code, signal)) return '';
|
|
9
20
|
if (signal === 'SIGKILL') return 'agent_timeout';
|
|
10
21
|
return 'spawn_session_crashed';
|
|
11
22
|
}
|
|
@@ -66,7 +77,7 @@ export function resolveLifecycleExitState({ runtime, code, signal, stopCause })
|
|
|
66
77
|
reachability: 'reachable',
|
|
67
78
|
availability: 'available',
|
|
68
79
|
runtimeState: 'stopped',
|
|
69
|
-
reason: signal
|
|
80
|
+
reason: isTerminationExit(code, signal) ? 'terminated' : 'stopped',
|
|
70
81
|
};
|
|
71
82
|
}
|
|
72
83
|
return {
|
|
@@ -30,6 +30,21 @@ export async function runSubmitToLibraryTool({
|
|
|
30
30
|
'/content-library/submit',
|
|
31
31
|
buildSubmitToLibraryBody(args, currentWorkspaceId)
|
|
32
32
|
);
|
|
33
|
+
// Server returns 2xx + body.error for transient "still processing" cases
|
|
34
|
+
// (e.g. HTTP 202 + {error: "video is still uploading, retry shortly"} when
|
|
35
|
+
// the workspace file's object_status is still 'uploading'). The HTTP client
|
|
36
|
+
// treats any 2xx as success and returns the body verbatim, so we must
|
|
37
|
+
// surface body.error here — otherwise the agent reads `data.itemId` as
|
|
38
|
+
// undefined and (because the literal "undefined" looks like proof the
|
|
39
|
+
// submit silently failed) tends to redo the entire video instead of just
|
|
40
|
+
// retrying submit_to_library. Fail loudly with the server's message so the
|
|
41
|
+
// agent retries the right step.
|
|
42
|
+
if (data && typeof data === 'object' && typeof data.error === 'string' && data.error.trim()) {
|
|
43
|
+
return toolError(`submit_to_library not ready: ${data.error}. Retry submit_to_library with the same video_path in a few seconds — do NOT re-record or re-compose the video.`);
|
|
44
|
+
}
|
|
45
|
+
if (!data || typeof data.itemId !== 'string' || !data.itemId) {
|
|
46
|
+
return toolError(`submit_to_library returned no itemId. Server response: ${JSON.stringify(data).slice(0, 300)}. Retry submit_to_library with the same video_path — do NOT re-record or re-compose.`);
|
|
47
|
+
}
|
|
33
48
|
return toolText(
|
|
34
49
|
`Submitted to content library: itemId=${data.itemId}, version=${data.version}, kind=${data.kind ?? 'content_video_draft'}`
|
|
35
50
|
);
|