neoagent 2.3.1-beta.66 → 2.3.1-beta.67
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/package.json +6 -1
- package/runtime/paths.js +93 -0
- package/runtime/paths.test.js +29 -0
- package/server/index.js +2 -14
- package/server/public/.last_build_id +1 -1
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +4 -4
- package/server/services/ai/deliverables/workflows.js +14 -2
- package/server/services/ai/integrated_tools/index.js +98 -0
- package/server/services/ai/integrated_tools/remotion.js +365 -0
- package/server/services/ai/integrated_tools/shared.js +199 -0
- package/server/services/ai/integrated_tools/slidev.js +205 -0
- package/server/services/ai/tools.js +16 -0
- package/server/services/manager.js +6 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neoagent",
|
|
3
|
-
"version": "2.3.1-beta.
|
|
3
|
+
"version": "2.3.1-beta.67",
|
|
4
4
|
"description": "Proactive personal AI agent with no limits",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "server/index.js",
|
|
@@ -54,6 +54,9 @@
|
|
|
54
54
|
"@anthropic-ai/sdk": "^0.39.0",
|
|
55
55
|
"@google/generative-ai": "^0.24.0",
|
|
56
56
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
57
|
+
"@remotion/cli": "^4.0.459",
|
|
58
|
+
"@slidev/cli": "^52.15.2",
|
|
59
|
+
"@slidev/theme-default": "^0.25.0",
|
|
57
60
|
"baileys": "^6.7.21",
|
|
58
61
|
"bcrypt": "^6.0.0",
|
|
59
62
|
"better-sqlite3": "^11.8.1",
|
|
@@ -73,11 +76,13 @@
|
|
|
73
76
|
"nodemailer": "^8.0.5",
|
|
74
77
|
"openai": "^4.85.4",
|
|
75
78
|
"otplib": "^13.4.0",
|
|
79
|
+
"playwright-chromium": "^1.59.1",
|
|
76
80
|
"proper-lockfile": "^4.1.2",
|
|
77
81
|
"puppeteer-core": "^24.40.0",
|
|
78
82
|
"puppeteer-extra": "^3.3.6",
|
|
79
83
|
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
|
80
84
|
"qrcode": "^1.5.4",
|
|
85
|
+
"remotion": "^4.0.459",
|
|
81
86
|
"sharp": "^0.34.5",
|
|
82
87
|
"socket.io": "^4.8.1",
|
|
83
88
|
"telegraf": "^4.16.3",
|
package/runtime/paths.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
|
+
const crypto = require('crypto');
|
|
2
3
|
const os = require('os');
|
|
3
4
|
const path = require('path');
|
|
5
|
+
const { parseEnv } = require('./env');
|
|
4
6
|
|
|
5
7
|
const APP_DIR = path.resolve(__dirname, '..');
|
|
6
8
|
const HOME_DIR = os.homedir();
|
|
@@ -95,6 +97,96 @@ function migrateLegacyRuntime(logger = () => {}) {
|
|
|
95
97
|
return changed;
|
|
96
98
|
}
|
|
97
99
|
|
|
100
|
+
function readEnvFileRaw(envFile = ENV_FILE) {
|
|
101
|
+
try {
|
|
102
|
+
return fs.readFileSync(envFile, 'utf8');
|
|
103
|
+
} catch {
|
|
104
|
+
return '';
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function upsertEnvValue(envFile, key, value) {
|
|
109
|
+
const raw = readEnvFileRaw(envFile);
|
|
110
|
+
const lines = raw ? raw.split('\n') : [];
|
|
111
|
+
let replaced = false;
|
|
112
|
+
|
|
113
|
+
for (let i = 0; i < lines.length; i++) {
|
|
114
|
+
if (lines[i].startsWith(`${key}=`)) {
|
|
115
|
+
lines[i] = `${key}=${value}`;
|
|
116
|
+
replaced = true;
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!replaced) {
|
|
122
|
+
lines.push(`${key}=${value}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const output = lines.filter((line, idx, arr) => idx !== arr.length - 1 || line !== '').join('\n') + '\n';
|
|
126
|
+
fs.mkdirSync(path.dirname(envFile), { recursive: true });
|
|
127
|
+
fs.writeFileSync(envFile, output, { mode: 0o600 });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function generateSecret(bytes = 32) {
|
|
131
|
+
return crypto.randomBytes(bytes).toString('hex');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function isPlaceholderValue(value, placeholders) {
|
|
135
|
+
const secret = String(value || '').trim();
|
|
136
|
+
return !secret || placeholders.has(secret);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function isValidVmGuestToken(value) {
|
|
140
|
+
const secret = String(value || '').trim();
|
|
141
|
+
if (!secret || secret.length < 32) return false;
|
|
142
|
+
if (/^(change|replace|set|your|example|sample|placeholder|token|secret)[-_a-z0-9]*$/i.test(secret)) return false;
|
|
143
|
+
if (/change-this-guest-token-before-prod/i.test(secret)) return false;
|
|
144
|
+
if (/^(.)\1+$/.test(secret)) return false;
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function ensureSecureRuntimeEnv({ envFile = ENV_FILE, env = process.env, logger = console } = {}) {
|
|
149
|
+
const raw = readEnvFileRaw(envFile);
|
|
150
|
+
const parsed = parseEnv(raw);
|
|
151
|
+
const changes = [];
|
|
152
|
+
const sessionPlaceholders = new Set([
|
|
153
|
+
'neoagent-dev-secret-change-me',
|
|
154
|
+
'change-this-to-a-random-secret-in-production',
|
|
155
|
+
'change-me-to-something-random',
|
|
156
|
+
]);
|
|
157
|
+
|
|
158
|
+
let sessionSecret = String(env.SESSION_SECRET || parsed.get('SESSION_SECRET') || '').trim();
|
|
159
|
+
if (isPlaceholderValue(sessionSecret, sessionPlaceholders)) {
|
|
160
|
+
sessionSecret = generateSecret(32);
|
|
161
|
+
env.SESSION_SECRET = sessionSecret;
|
|
162
|
+
upsertEnvValue(envFile, 'SESSION_SECRET', sessionSecret);
|
|
163
|
+
changes.push('SESSION_SECRET');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let guestToken = String(env.NEOAGENT_VM_GUEST_TOKEN || parsed.get('NEOAGENT_VM_GUEST_TOKEN') || '').trim();
|
|
167
|
+
if (!isValidVmGuestToken(guestToken)) {
|
|
168
|
+
guestToken = generateSecret(32);
|
|
169
|
+
env.NEOAGENT_VM_GUEST_TOKEN = guestToken;
|
|
170
|
+
upsertEnvValue(envFile, 'NEOAGENT_VM_GUEST_TOKEN', guestToken);
|
|
171
|
+
changes.push('NEOAGENT_VM_GUEST_TOKEN');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (changes.length > 0 && logger) {
|
|
175
|
+
const message = `Initialized secure runtime secrets: ${changes.join(', ')}`;
|
|
176
|
+
if (typeof logger.info === 'function') {
|
|
177
|
+
logger.info(message);
|
|
178
|
+
} else if (typeof logger.log === 'function') {
|
|
179
|
+
logger.log(message);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
changes,
|
|
185
|
+
sessionSecret: env.SESSION_SECRET || null,
|
|
186
|
+
guestToken: env.NEOAGENT_VM_GUEST_TOKEN || null,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
98
190
|
module.exports = {
|
|
99
191
|
APP_DIR,
|
|
100
192
|
HOME_DIR,
|
|
@@ -109,5 +201,6 @@ module.exports = {
|
|
|
109
201
|
LEGACY_DATA_DIR,
|
|
110
202
|
LEGACY_AGENT_DATA_DIR,
|
|
111
203
|
ensureRuntimeDirs,
|
|
204
|
+
ensureSecureRuntimeEnv,
|
|
112
205
|
migrateLegacyRuntime
|
|
113
206
|
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const test = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
const {
|
|
8
|
+
ensureSecureRuntimeEnv,
|
|
9
|
+
} = require('./paths');
|
|
10
|
+
|
|
11
|
+
test('ensureSecureRuntimeEnv generates runtime secrets when missing', () => {
|
|
12
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'neoagent-runtime-'));
|
|
13
|
+
const envFile = path.join(tempDir, '.env');
|
|
14
|
+
const env = {};
|
|
15
|
+
|
|
16
|
+
const result = ensureSecureRuntimeEnv({
|
|
17
|
+
envFile,
|
|
18
|
+
env,
|
|
19
|
+
logger: null,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const content = fs.readFileSync(envFile, 'utf8');
|
|
23
|
+
assert.match(content, /^SESSION_SECRET=/m);
|
|
24
|
+
assert.match(content, /^NEOAGENT_VM_GUEST_TOKEN=/m);
|
|
25
|
+
assert.equal(result.changes.includes('SESSION_SECRET'), true);
|
|
26
|
+
assert.equal(result.changes.includes('NEOAGENT_VM_GUEST_TOKEN'), true);
|
|
27
|
+
assert.ok(String(env.SESSION_SECRET || '').length >= 64);
|
|
28
|
+
assert.ok(String(env.NEOAGENT_VM_GUEST_TOKEN || '').length >= 64);
|
|
29
|
+
});
|
package/server/index.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const {
|
|
4
4
|
ENV_FILE,
|
|
5
5
|
LEGACY_ENV_FILE,
|
|
6
|
+
ensureSecureRuntimeEnv,
|
|
6
7
|
migrateLegacyRuntime,
|
|
7
8
|
ensureRuntimeDirs
|
|
8
9
|
} = require('../runtime/paths');
|
|
@@ -11,6 +12,7 @@ const dotenv = require('dotenv');
|
|
|
11
12
|
|
|
12
13
|
migrateLegacyRuntime();
|
|
13
14
|
ensureRuntimeDirs();
|
|
15
|
+
ensureSecureRuntimeEnv({ logger: console });
|
|
14
16
|
dotenv.config({ path: LEGACY_ENV_FILE });
|
|
15
17
|
dotenv.config({ path: ENV_FILE, override: true });
|
|
16
18
|
|
|
@@ -19,10 +21,6 @@ const { createServer } = require('http');
|
|
|
19
21
|
|
|
20
22
|
const db = require('./db/database');
|
|
21
23
|
const { setupConsoleInterceptor } = require('./utils/logger');
|
|
22
|
-
const {
|
|
23
|
-
configuredSessionSecret,
|
|
24
|
-
isInsecureSessionSecret,
|
|
25
|
-
} = require('./services/account/session_secret');
|
|
26
24
|
const { validateOrigin } = require('./config/origins');
|
|
27
25
|
const {
|
|
28
26
|
applyHttpMiddleware,
|
|
@@ -87,16 +85,6 @@ function logStartupConfig() {
|
|
|
87
85
|
|
|
88
86
|
logStartupConfig();
|
|
89
87
|
|
|
90
|
-
if (!configuredSessionSecret()) {
|
|
91
|
-
console.warn(
|
|
92
|
-
'WARNING: SESSION_SECRET not set — using a process-local random fallback. Set it in .env before exposing this server.'
|
|
93
|
-
);
|
|
94
|
-
} else if (isInsecureSessionSecret()) {
|
|
95
|
-
console.warn(
|
|
96
|
-
'WARNING: SESSION_SECRET uses a known placeholder value. Replace it with a random secret before exposing this server.'
|
|
97
|
-
);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
88
|
const app = express();
|
|
101
89
|
app.disable('x-powered-by');
|
|
102
90
|
const httpServer = createServer(app);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
d4a028d4f0ac72510e406325f227aae2
|
|
@@ -37,6 +37,6 @@ _flutter.buildConfig = {"engineRevision":"42d3d75a56efe1a2e9902f52dc8006099c45d9
|
|
|
37
37
|
|
|
38
38
|
_flutter.loader.load({
|
|
39
39
|
serviceWorkerSettings: {
|
|
40
|
-
serviceWorkerVersion: "
|
|
40
|
+
serviceWorkerVersion: "889725162" /* Flutter's service worker is deprecated and will be removed in a future Flutter release. */
|
|
41
41
|
}
|
|
42
42
|
});
|
|
@@ -127230,7 +127230,7 @@ r===$&&A.b()
|
|
|
127230
127230
|
o.push(A.id(p,A.iS(!1,new A.a3(B.tE,A.dZ(new A.cU(B.h8,new A.a5p(r,p),p),p,p),p),!1,B.I,!0),p,p,0,0,0,p))}r=!1
|
|
127231
127231
|
if(!s.ay)if(!s.ch){r=s.e
|
|
127232
127232
|
r===$&&A.b()
|
|
127233
|
-
r=B.b.A("
|
|
127233
|
+
r=B.b.A("mp0we32e-3c36f1d").length!==0&&r.b}if(r){r=s.d
|
|
127234
127234
|
r===$&&A.b()
|
|
127235
127235
|
r=r.V&&!r.a0?84:0
|
|
127236
127236
|
q=s.e
|
|
@@ -131894,7 +131894,7 @@ $S:324}
|
|
|
131894
131894
|
A.Y0.prototype={}
|
|
131895
131895
|
A.R0.prototype={
|
|
131896
131896
|
mJ(a){var s=this
|
|
131897
|
-
if(B.b.A("
|
|
131897
|
+
if(B.b.A("mp0we32e-3c36f1d").length===0||s.a!=null)return
|
|
131898
131898
|
s.zY()
|
|
131899
131899
|
s.a=A.pN(B.Pr,new A.b3g(s))},
|
|
131900
131900
|
zY(){var s=0,r=A.l(t.H),q,p=2,o=[],n=this,m,l,k,j,i,h,g,f
|
|
@@ -131912,7 +131912,7 @@ if(!t.f.b(k)){s=1
|
|
|
131912
131912
|
break}i=J.Z(k,"buildId")
|
|
131913
131913
|
h=i==null?null:B.b.A(J.r(i))
|
|
131914
131914
|
j=h==null?"":h
|
|
131915
|
-
if(J.bi(j)===0||J.c(j,"
|
|
131915
|
+
if(J.bi(j)===0||J.c(j,"mp0we32e-3c36f1d")){s=1
|
|
131916
131916
|
break}n.b=!0
|
|
131917
131917
|
n.J()
|
|
131918
131918
|
p=2
|
|
@@ -131929,7 +131929,7 @@ case 2:return A.i(o.at(-1),r)}})
|
|
|
131929
131929
|
return A.k($async$zY,r)},
|
|
131930
131930
|
v_(){var s=0,r=A.l(t.H),q,p=2,o=[],n=this,m,l,k,j,i,h,g,f,e,d,c,b,a,a0,a1
|
|
131931
131931
|
var $async$v_=A.h(function(a2,a3){if(a2===1){o.push(a3)
|
|
131932
|
-
s=p}for(;;)switch(s){case 0:if(B.b.A("
|
|
131932
|
+
s=p}for(;;)switch(s){case 0:if(B.b.A("mp0we32e-3c36f1d").length===0||n.c){s=1
|
|
131933
131933
|
break}n.c=!0
|
|
131934
131934
|
n.J()
|
|
131935
131935
|
p=4
|
|
@@ -43,6 +43,7 @@ function createWorkflow(config) {
|
|
|
43
43
|
supportingCapabilities: request.supportingCapabilities,
|
|
44
44
|
preferredTools: [...this.preferredTools],
|
|
45
45
|
expectedOutputs: [...this.expectedOutputs],
|
|
46
|
+
summaryHints: [...this.summaryHints],
|
|
46
47
|
validationRules: [
|
|
47
48
|
`Produce deliverable artifacts of type "${config.type}" before finishing.`,
|
|
48
49
|
'Mention output files or artifact links explicitly in the final response when available.',
|
|
@@ -110,10 +111,15 @@ const WORKFLOWS = [
|
|
|
110
111
|
createWorkflow({
|
|
111
112
|
type: 'slides',
|
|
112
113
|
displayName: 'Slides',
|
|
113
|
-
preferredTools: ['write_file', 'edit_file', 'execute_command', 'browser_screenshot', 'generate_image'],
|
|
114
|
+
preferredTools: ['generate_slide_deck', 'write_file', 'edit_file', 'execute_command', 'browser_screenshot', 'generate_image'],
|
|
114
115
|
expectedOutputs: ['presentation deck', 'exported slide file', 'visual proof'],
|
|
115
116
|
expectedArtifactKinds: ['slides', 'document', 'image'],
|
|
116
117
|
expectedExtensions: ['.ppt', '.pptx', '.pdf', '.html', '.png', '.jpg', '.jpeg'],
|
|
118
|
+
summaryHints: [
|
|
119
|
+
'Prefer generate_slide_deck for final deck creation and export.',
|
|
120
|
+
'Use 5-12 slides with clear titles and concise body copy.',
|
|
121
|
+
'Request pdf or pdf+pptx exports when the user wants a finished shareable deck.',
|
|
122
|
+
],
|
|
117
123
|
}),
|
|
118
124
|
createWorkflow({
|
|
119
125
|
type: 'document',
|
|
@@ -161,10 +167,15 @@ const WORKFLOWS = [
|
|
|
161
167
|
createWorkflow({
|
|
162
168
|
type: 'video',
|
|
163
169
|
displayName: 'Video',
|
|
164
|
-
preferredTools: ['execute_command', 'write_file', 'edit_file'],
|
|
170
|
+
preferredTools: ['generate_video_with_remotion', 'execute_command', 'write_file', 'edit_file'],
|
|
165
171
|
expectedOutputs: ['rendered video asset'],
|
|
166
172
|
expectedArtifactKinds: ['video'],
|
|
167
173
|
expectedExtensions: ['.mp4', '.mov', '.m4v', '.webm'],
|
|
174
|
+
summaryHints: [
|
|
175
|
+
'Prefer generate_video_with_remotion for the final rendered asset.',
|
|
176
|
+
'Use 3-10 scenes with explicit duration_seconds and concise on-screen text.',
|
|
177
|
+
'Attach local assets by absolute image_path or audio_path when available.',
|
|
178
|
+
],
|
|
168
179
|
}),
|
|
169
180
|
];
|
|
170
181
|
|
|
@@ -184,6 +195,7 @@ function buildDeliverableWorkflowGuidance(plan) {
|
|
|
184
195
|
plan.requestedOutputs?.length ? `Requested outputs: ${plan.requestedOutputs.join(', ')}` : '',
|
|
185
196
|
plan.preferredTools?.length ? `Preferred tools/capabilities: ${plan.preferredTools.join(', ')}` : '',
|
|
186
197
|
plan.expectedOutputs?.length ? `Expected artifacts: ${plan.expectedOutputs.join(', ')}` : '',
|
|
198
|
+
plan.summaryHints?.length ? `Execution guidance: ${plan.summaryHints.join(' ')}` : '',
|
|
187
199
|
'Before finishing, ensure the final deliverable exists, validate it, and summarize the produced artifacts clearly.',
|
|
188
200
|
].filter(Boolean).join('\n');
|
|
189
201
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { generateSlideDeck } = require('./slidev');
|
|
4
|
+
const { generateVideoWithRemotion } = require('./remotion');
|
|
5
|
+
|
|
6
|
+
function getIntegratedToolDefinitions() {
|
|
7
|
+
return [
|
|
8
|
+
{
|
|
9
|
+
name: 'generate_slide_deck',
|
|
10
|
+
description: 'Generate a polished presentation using Slidev and export finished artifacts. Prefer this for decks instead of raw file-writing. Best practice: pass a clear title and a complete slides array, and request export_formats ["pdf"] or ["pdf","pptx"] for a shareable final result.',
|
|
11
|
+
parameters: {
|
|
12
|
+
type: 'object',
|
|
13
|
+
properties: {
|
|
14
|
+
title: { type: 'string', description: 'Deck title.' },
|
|
15
|
+
subtitle: { type: 'string', description: 'Optional deck subtitle or framing line.' },
|
|
16
|
+
theme: { type: 'string', description: 'Slidev theme name. Defaults to "default".' },
|
|
17
|
+
deck_markdown: { type: 'string', description: 'Optional full Slidev markdown deck. Use this when you want exact Slidev syntax control.' },
|
|
18
|
+
slides: {
|
|
19
|
+
type: 'array',
|
|
20
|
+
description: 'Structured slide definitions. Use this for most decks if you do not need custom Slidev markdown.',
|
|
21
|
+
items: {
|
|
22
|
+
type: 'object',
|
|
23
|
+
properties: {
|
|
24
|
+
title: { type: 'string', description: 'Slide title.' },
|
|
25
|
+
body: { type: 'string', description: 'Short narrative paragraph or statement.' },
|
|
26
|
+
bullets: { type: 'array', items: { type: 'string' }, description: 'Bullet list for the slide.' },
|
|
27
|
+
notes: { type: 'string', description: 'Presenter notes.' },
|
|
28
|
+
image_url: { type: 'string', description: 'Optional remote image URL to embed.' },
|
|
29
|
+
image_path: { type: 'string', description: 'Optional absolute local image path to embed.' },
|
|
30
|
+
layout: { type: 'string', description: 'Optional Slidev layout, for example cover, section, statement, quote, or two-cols.' },
|
|
31
|
+
className: { type: 'string', description: 'Optional Slidev class value.' },
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
export_formats: {
|
|
36
|
+
type: 'array',
|
|
37
|
+
items: { type: 'string', enum: ['pdf', 'pptx', 'png'] },
|
|
38
|
+
description: 'Finished output formats. Defaults to ["pdf"].',
|
|
39
|
+
},
|
|
40
|
+
filename_base: { type: 'string', description: 'Optional output filename base.' },
|
|
41
|
+
},
|
|
42
|
+
required: ['title'],
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: 'generate_video_with_remotion',
|
|
47
|
+
description: 'Generate a finished MP4 video using Remotion. Prefer this for explainers, launch videos, reels, and narrated visual summaries. Best practice: pass 3-10 scenes with explicit duration_seconds, concise on-screen text, and optional image_path or image_url assets.',
|
|
48
|
+
parameters: {
|
|
49
|
+
type: 'object',
|
|
50
|
+
properties: {
|
|
51
|
+
title: { type: 'string', description: 'Video title or opening headline.' },
|
|
52
|
+
subtitle: { type: 'string', description: 'Optional supporting line.' },
|
|
53
|
+
style: { type: 'string', description: 'High-level visual style direction.' },
|
|
54
|
+
aspect_ratio: { type: 'string', enum: ['16:9', '9:16', '1:1', '4:5'], description: 'Video canvas aspect ratio. Defaults to 16:9.' },
|
|
55
|
+
fps: { type: 'number', description: 'Frames per second. Defaults to 30.' },
|
|
56
|
+
audio_path: { type: 'string', description: 'Optional absolute local path to a soundtrack or voiceover file.' },
|
|
57
|
+
scenes: {
|
|
58
|
+
type: 'array',
|
|
59
|
+
description: 'Scene list in playback order.',
|
|
60
|
+
items: {
|
|
61
|
+
type: 'object',
|
|
62
|
+
properties: {
|
|
63
|
+
title: { type: 'string', description: 'Scene headline.' },
|
|
64
|
+
body: { type: 'string', description: 'Supporting sentence or paragraph.' },
|
|
65
|
+
bullets: { type: 'array', items: { type: 'string' }, description: 'Optional bullets to show in the scene.' },
|
|
66
|
+
duration_seconds: { type: 'number', description: 'Scene duration in seconds.' },
|
|
67
|
+
image_url: { type: 'string', description: 'Optional remote image URL.' },
|
|
68
|
+
image_path: { type: 'string', description: 'Optional absolute local image path.' },
|
|
69
|
+
accent_color: { type: 'string', description: 'Optional accent color, for example #7dd3fc.' },
|
|
70
|
+
background_color: { type: 'string', description: 'Optional scene background color.' },
|
|
71
|
+
align: { type: 'string', enum: ['left', 'center', 'right'], description: 'Text alignment.' },
|
|
72
|
+
},
|
|
73
|
+
required: ['title'],
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
filename_base: { type: 'string', description: 'Optional output filename base.' },
|
|
77
|
+
},
|
|
78
|
+
required: ['title', 'scenes'],
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function executeIntegratedTool(toolName, args, context = {}) {
|
|
85
|
+
switch (String(toolName || '').trim()) {
|
|
86
|
+
case 'generate_slide_deck':
|
|
87
|
+
return generateSlideDeck(args, context);
|
|
88
|
+
case 'generate_video_with_remotion':
|
|
89
|
+
return generateVideoWithRemotion(args, context);
|
|
90
|
+
default:
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = {
|
|
96
|
+
executeIntegratedTool,
|
|
97
|
+
getIntegratedToolDefinitions,
|
|
98
|
+
};
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const {
|
|
5
|
+
copyAssetIntoJob,
|
|
6
|
+
createArtifactDescriptor,
|
|
7
|
+
createJobDir,
|
|
8
|
+
ensureDir,
|
|
9
|
+
normalizeFilenameBase,
|
|
10
|
+
promoteArtifactDescriptor,
|
|
11
|
+
resolveRepoBinary,
|
|
12
|
+
runCheckedCommand,
|
|
13
|
+
shellEscape,
|
|
14
|
+
writeJsonFile,
|
|
15
|
+
writeTextFile,
|
|
16
|
+
} = require('./shared');
|
|
17
|
+
|
|
18
|
+
const REMOTION_BIN = resolveRepoBinary('remotion');
|
|
19
|
+
const ASPECT_RATIOS = {
|
|
20
|
+
'16:9': { width: 1920, height: 1080 },
|
|
21
|
+
'9:16': { width: 1080, height: 1920 },
|
|
22
|
+
'1:1': { width: 1080, height: 1080 },
|
|
23
|
+
'4:5': { width: 1080, height: 1350 },
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function normalizeScene(scene = {}) {
|
|
27
|
+
const durationSeconds = Math.max(1.5, Number(scene.duration_seconds ?? scene.durationSeconds) || 3);
|
|
28
|
+
return {
|
|
29
|
+
title: String(scene.title || '').trim(),
|
|
30
|
+
body: String(scene.body || '').trim(),
|
|
31
|
+
bullets: Array.isArray(scene.bullets)
|
|
32
|
+
? scene.bullets.map((item) => String(item || '').trim()).filter(Boolean).slice(0, 5)
|
|
33
|
+
: [],
|
|
34
|
+
durationSeconds,
|
|
35
|
+
accentColor: String(scene.accent_color || scene.accentColor || '#7dd3fc').trim() || '#7dd3fc',
|
|
36
|
+
backgroundColor: String(scene.background_color || scene.backgroundColor || '#08111f').trim() || '#08111f',
|
|
37
|
+
align: ['left', 'center', 'right'].includes(String(scene.align || '').trim())
|
|
38
|
+
? String(scene.align).trim()
|
|
39
|
+
: 'left',
|
|
40
|
+
imagePath: String(scene.image_path || '').trim(),
|
|
41
|
+
imageUrl: String(scene.image_url || '').trim(),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalizeProps(args, jobDir) {
|
|
46
|
+
const assetsDir = ensureDir(path.join(jobDir, 'public', 'assets'));
|
|
47
|
+
const scenes = (Array.isArray(args.scenes) ? args.scenes : [])
|
|
48
|
+
.map(normalizeScene)
|
|
49
|
+
.filter((scene) => scene.title || scene.body || scene.bullets.length > 0);
|
|
50
|
+
if (scenes.length === 0) {
|
|
51
|
+
throw new Error('generate_video_with_remotion requires a non-empty scenes array.');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const preparedScenes = scenes.map((scene, index) => {
|
|
55
|
+
let imageSrc = scene.imageUrl || '';
|
|
56
|
+
if (!imageSrc && scene.imagePath) {
|
|
57
|
+
const asset = copyAssetIntoJob(scene.imagePath, assetsDir, `scene-${index + 1}`);
|
|
58
|
+
imageSrc = `/assets/${asset.relativePath}`;
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
...scene,
|
|
62
|
+
imageSrc,
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
let audioSrc = '';
|
|
67
|
+
if (args.audio_path) {
|
|
68
|
+
const asset = copyAssetIntoJob(args.audio_path, assetsDir, 'soundtrack');
|
|
69
|
+
audioSrc = `/assets/${asset.relativePath}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
title: String(args.title || 'NeoAgent video').trim(),
|
|
74
|
+
subtitle: String(args.subtitle || '').trim(),
|
|
75
|
+
style: String(args.style || 'editorial cinematic').trim(),
|
|
76
|
+
fps: Math.max(12, Math.min(60, Number(args.fps) || 30)),
|
|
77
|
+
aspectRatio: ASPECT_RATIOS[String(args.aspect_ratio || '').trim()] ? String(args.aspect_ratio).trim() : '16:9',
|
|
78
|
+
scenes: preparedScenes,
|
|
79
|
+
audioSrc,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function buildRootFile() {
|
|
84
|
+
return `const React = require('react');
|
|
85
|
+
const {registerRoot, Composition} = require('remotion');
|
|
86
|
+
const props = require('./props.json');
|
|
87
|
+
const {NeoAgentVideo} = require('./VideoComposition');
|
|
88
|
+
|
|
89
|
+
const sizes = {
|
|
90
|
+
'16:9': {width: 1920, height: 1080},
|
|
91
|
+
'9:16': {width: 1080, height: 1920},
|
|
92
|
+
'1:1': {width: 1080, height: 1080},
|
|
93
|
+
'4:5': {width: 1080, height: 1350},
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const fps = Number(props.fps) || 30;
|
|
97
|
+
const sceneFrames = (props.scenes || []).map((scene) => Math.max(1, Math.round((Number(scene.durationSeconds) || 3) * fps)));
|
|
98
|
+
const durationInFrames = sceneFrames.reduce((sum, next) => sum + next, 0);
|
|
99
|
+
const size = sizes[props.aspectRatio] || sizes['16:9'];
|
|
100
|
+
|
|
101
|
+
const RemotionRoot = () => (
|
|
102
|
+
React.createElement(
|
|
103
|
+
React.Fragment,
|
|
104
|
+
null,
|
|
105
|
+
React.createElement(Composition, {
|
|
106
|
+
id: 'NeoAgentVideo',
|
|
107
|
+
component: NeoAgentVideo,
|
|
108
|
+
durationInFrames,
|
|
109
|
+
fps,
|
|
110
|
+
width: size.width,
|
|
111
|
+
height: size.height,
|
|
112
|
+
defaultProps: props,
|
|
113
|
+
}),
|
|
114
|
+
)
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
registerRoot(RemotionRoot);
|
|
118
|
+
`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function buildCompositionFile() {
|
|
122
|
+
return `const React = require('react');
|
|
123
|
+
const {AbsoluteFill, Audio, Img, Sequence, interpolate, spring, useCurrentFrame, useVideoConfig} = require('remotion');
|
|
124
|
+
|
|
125
|
+
const FONT_STACK = 'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
|
|
126
|
+
|
|
127
|
+
const shellStyle = {
|
|
128
|
+
position: 'relative',
|
|
129
|
+
overflow: 'hidden',
|
|
130
|
+
fontFamily: FONT_STACK,
|
|
131
|
+
color: '#f8fafc',
|
|
132
|
+
padding: 72,
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const backdropStyle = {
|
|
136
|
+
position: 'absolute',
|
|
137
|
+
inset: 0,
|
|
138
|
+
background: 'radial-gradient(circle at top left, rgba(125,211,252,0.28), transparent 34%), radial-gradient(circle at bottom right, rgba(168,85,247,0.18), transparent 40%)',
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const cardStyle = {
|
|
142
|
+
position: 'relative',
|
|
143
|
+
marginTop: 40,
|
|
144
|
+
borderRadius: 36,
|
|
145
|
+
padding: '40px 44px',
|
|
146
|
+
border: '1px solid rgba(255,255,255,0.14)',
|
|
147
|
+
background: 'rgba(7, 14, 25, 0.68)',
|
|
148
|
+
boxShadow: '0 28px 90px rgba(0,0,0,0.28)',
|
|
149
|
+
backdropFilter: 'blur(18px)',
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const SceneView = ({scene}) => {
|
|
153
|
+
const frame = useCurrentFrame();
|
|
154
|
+
const {fps, durationInFrames} = useVideoConfig();
|
|
155
|
+
const entrance = spring({frame, fps, config: {damping: 18, mass: 0.9}});
|
|
156
|
+
const exitOpacity = interpolate(frame, [Math.max(0, durationInFrames - 18), durationInFrames], [1, 0], {
|
|
157
|
+
extrapolateLeft: 'clamp',
|
|
158
|
+
extrapolateRight: 'clamp',
|
|
159
|
+
});
|
|
160
|
+
const translateY = interpolate(entrance, [0, 1], [34, 0]);
|
|
161
|
+
const textAlign = scene.align || 'left';
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
React.createElement(AbsoluteFill, {
|
|
165
|
+
style: {
|
|
166
|
+
...shellStyle,
|
|
167
|
+
justifyContent: 'center',
|
|
168
|
+
background: scene.backgroundColor || '#08111f',
|
|
169
|
+
opacity: exitOpacity,
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
React.createElement('div', {style: backdropStyle}),
|
|
173
|
+
scene.imageSrc
|
|
174
|
+
? React.createElement(Img, {
|
|
175
|
+
src: scene.imageSrc,
|
|
176
|
+
style: {
|
|
177
|
+
position: 'absolute',
|
|
178
|
+
inset: 0,
|
|
179
|
+
width: '100%',
|
|
180
|
+
height: '100%',
|
|
181
|
+
objectFit: 'cover',
|
|
182
|
+
opacity: 0.18,
|
|
183
|
+
},
|
|
184
|
+
})
|
|
185
|
+
: null,
|
|
186
|
+
React.createElement('div', {
|
|
187
|
+
style: {
|
|
188
|
+
...cardStyle,
|
|
189
|
+
transform: \`translateY(\${translateY}px)\`,
|
|
190
|
+
maxWidth: scene.imageSrc ? '62%' : '100%',
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
React.createElement('div', {
|
|
194
|
+
style: {
|
|
195
|
+
width: 110,
|
|
196
|
+
height: 8,
|
|
197
|
+
borderRadius: 999,
|
|
198
|
+
background: scene.accentColor || '#7dd3fc',
|
|
199
|
+
marginBottom: 24,
|
|
200
|
+
},
|
|
201
|
+
}),
|
|
202
|
+
React.createElement('h1', {
|
|
203
|
+
style: {
|
|
204
|
+
margin: 0,
|
|
205
|
+
fontSize: 76,
|
|
206
|
+
lineHeight: 1.02,
|
|
207
|
+
letterSpacing: '-0.06em',
|
|
208
|
+
textAlign,
|
|
209
|
+
},
|
|
210
|
+
}, scene.title || 'Scene'),
|
|
211
|
+
scene.body
|
|
212
|
+
? React.createElement('p', {
|
|
213
|
+
style: {
|
|
214
|
+
margin: '20px 0 0 0',
|
|
215
|
+
maxWidth: 900,
|
|
216
|
+
fontSize: 32,
|
|
217
|
+
lineHeight: 1.35,
|
|
218
|
+
color: 'rgba(226,232,240,0.92)',
|
|
219
|
+
textAlign,
|
|
220
|
+
},
|
|
221
|
+
}, scene.body)
|
|
222
|
+
: null,
|
|
223
|
+
scene.bullets && scene.bullets.length > 0
|
|
224
|
+
? React.createElement('ul', {
|
|
225
|
+
style: {
|
|
226
|
+
margin: '28px 0 0 0',
|
|
227
|
+
paddingLeft: textAlign === 'center' ? 24 : 34,
|
|
228
|
+
fontSize: 28,
|
|
229
|
+
lineHeight: 1.45,
|
|
230
|
+
color: 'rgba(226,232,240,0.88)',
|
|
231
|
+
},
|
|
232
|
+
}, scene.bullets.map((bullet, index) => React.createElement('li', {
|
|
233
|
+
key: String(index),
|
|
234
|
+
style: {marginBottom: 12},
|
|
235
|
+
}, bullet)))
|
|
236
|
+
: null,
|
|
237
|
+
),
|
|
238
|
+
scene.imageSrc
|
|
239
|
+
? React.createElement('div', {
|
|
240
|
+
style: {
|
|
241
|
+
position: 'absolute',
|
|
242
|
+
right: 72,
|
|
243
|
+
bottom: 72,
|
|
244
|
+
top: 72,
|
|
245
|
+
width: '30%',
|
|
246
|
+
borderRadius: 32,
|
|
247
|
+
overflow: 'hidden',
|
|
248
|
+
border: '1px solid rgba(255,255,255,0.16)',
|
|
249
|
+
boxShadow: '0 22px 70px rgba(0,0,0,0.28)',
|
|
250
|
+
transform: \`translateY(\${translateY * 0.7}px)\`,
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
React.createElement(Img, {
|
|
254
|
+
src: scene.imageSrc,
|
|
255
|
+
style: {
|
|
256
|
+
width: '100%',
|
|
257
|
+
height: '100%',
|
|
258
|
+
objectFit: 'cover',
|
|
259
|
+
},
|
|
260
|
+
}),
|
|
261
|
+
)
|
|
262
|
+
: null,
|
|
263
|
+
)
|
|
264
|
+
);
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const NeoAgentVideo = (props) => {
|
|
268
|
+
const fps = Number(props.fps) || 30;
|
|
269
|
+
const scenes = Array.isArray(props.scenes) ? props.scenes : [];
|
|
270
|
+
let cursor = 0;
|
|
271
|
+
|
|
272
|
+
return (
|
|
273
|
+
React.createElement(AbsoluteFill, {style: {backgroundColor: '#020617'}},
|
|
274
|
+
props.audioSrc ? React.createElement(Audio, {src: props.audioSrc}) : null,
|
|
275
|
+
scenes.map((scene, index) => {
|
|
276
|
+
const durationInFrames = Math.max(1, Math.round((Number(scene.durationSeconds) || 3) * fps));
|
|
277
|
+
const startFrom = cursor;
|
|
278
|
+
cursor += durationInFrames;
|
|
279
|
+
return React.createElement(Sequence, {
|
|
280
|
+
key: String(index),
|
|
281
|
+
from: startFrom,
|
|
282
|
+
durationInFrames,
|
|
283
|
+
}, React.createElement(SceneView, {scene}));
|
|
284
|
+
}),
|
|
285
|
+
)
|
|
286
|
+
);
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
exports.NeoAgentVideo = NeoAgentVideo;
|
|
290
|
+
`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function generateVideoWithRemotion(args, context = {}) {
|
|
294
|
+
if (!context.cliExecutor || typeof context.cliExecutor.execute !== 'function') {
|
|
295
|
+
throw new Error('CLI executor is unavailable for Remotion rendering.');
|
|
296
|
+
}
|
|
297
|
+
const filenameBase = normalizeFilenameBase(args.filename_base || args.title || 'video', 'video');
|
|
298
|
+
const jobDir = createJobDir('remotion', filenameBase);
|
|
299
|
+
const entryPath = path.join(jobDir, 'index.js');
|
|
300
|
+
const compositionPath = path.join(jobDir, 'VideoComposition.js');
|
|
301
|
+
const propsPath = path.join(jobDir, 'props.json');
|
|
302
|
+
const outputPath = path.join(jobDir, `${filenameBase}.mp4`);
|
|
303
|
+
const props = normalizeProps(args, jobDir);
|
|
304
|
+
const size = ASPECT_RATIOS[props.aspectRatio] || ASPECT_RATIOS['16:9'];
|
|
305
|
+
const durationInFrames = props.scenes.reduce((sum, scene) => (
|
|
306
|
+
sum + Math.max(1, Math.round(scene.durationSeconds * props.fps))
|
|
307
|
+
), 0);
|
|
308
|
+
|
|
309
|
+
writeTextFile(entryPath, buildRootFile());
|
|
310
|
+
writeTextFile(compositionPath, buildCompositionFile());
|
|
311
|
+
writeJsonFile(propsPath, props);
|
|
312
|
+
|
|
313
|
+
const command = [
|
|
314
|
+
shellEscape(REMOTION_BIN),
|
|
315
|
+
'render',
|
|
316
|
+
shellEscape(entryPath),
|
|
317
|
+
shellEscape('NeoAgentVideo'),
|
|
318
|
+
shellEscape(outputPath),
|
|
319
|
+
'--props',
|
|
320
|
+
shellEscape(propsPath),
|
|
321
|
+
'--codec',
|
|
322
|
+
shellEscape('h264'),
|
|
323
|
+
'--fps',
|
|
324
|
+
shellEscape(String(props.fps)),
|
|
325
|
+
'--width',
|
|
326
|
+
shellEscape(String(size.width)),
|
|
327
|
+
'--height',
|
|
328
|
+
shellEscape(String(size.height)),
|
|
329
|
+
'--duration',
|
|
330
|
+
shellEscape(String(durationInFrames)),
|
|
331
|
+
'--overwrite',
|
|
332
|
+
].join(' ');
|
|
333
|
+
|
|
334
|
+
const result = await runCheckedCommand(context.cliExecutor, command, {
|
|
335
|
+
cwd: jobDir,
|
|
336
|
+
timeout: 20 * 60 * 1000,
|
|
337
|
+
errorPrefix: 'Remotion render failed.',
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
const videoDescriptor = createArtifactDescriptor(outputPath, {
|
|
341
|
+
kind: 'video',
|
|
342
|
+
label: path.basename(outputPath),
|
|
343
|
+
mimeType: 'video/mp4',
|
|
344
|
+
});
|
|
345
|
+
const promotedVideo = promoteArtifactDescriptor(videoDescriptor, context.artifactStore, context.userId);
|
|
346
|
+
|
|
347
|
+
return {
|
|
348
|
+
success: true,
|
|
349
|
+
tool: 'generate_video_with_remotion',
|
|
350
|
+
title: props.title,
|
|
351
|
+
artifacts: [promotedVideo],
|
|
352
|
+
message: 'Generated rendered video.',
|
|
353
|
+
render: {
|
|
354
|
+
aspectRatio: props.aspectRatio,
|
|
355
|
+
fps: props.fps,
|
|
356
|
+
durationInFrames,
|
|
357
|
+
sceneCount: props.scenes.length,
|
|
358
|
+
durationMs: result.durationMs,
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
module.exports = {
|
|
364
|
+
generateVideoWithRemotion,
|
|
365
|
+
};
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
const { APP_DIR, DATA_DIR } = require('../../../../runtime/paths');
|
|
7
|
+
|
|
8
|
+
const TOOLING_ROOT = path.join(DATA_DIR, 'integrated-tools');
|
|
9
|
+
|
|
10
|
+
function ensureDir(dirPath) {
|
|
11
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
12
|
+
return dirPath;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function ensureRepoNodeModulesLink(targetDir) {
|
|
16
|
+
ensureDir(targetDir);
|
|
17
|
+
const sourceNodeModules = path.join(APP_DIR, 'node_modules');
|
|
18
|
+
const targetNodeModules = path.join(targetDir, 'node_modules');
|
|
19
|
+
if (fs.existsSync(targetNodeModules)) {
|
|
20
|
+
return targetNodeModules;
|
|
21
|
+
}
|
|
22
|
+
fs.symlinkSync(sourceNodeModules, targetNodeModules, 'junction');
|
|
23
|
+
return targetNodeModules;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function shellEscape(value) {
|
|
27
|
+
const text = String(value ?? '');
|
|
28
|
+
if (!text.length) return "''";
|
|
29
|
+
return `'${text.replace(/'/g, `'\\''`)}'`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizeFilenameBase(value, fallback = 'artifact') {
|
|
33
|
+
const normalized = String(value || '')
|
|
34
|
+
.trim()
|
|
35
|
+
.toLowerCase()
|
|
36
|
+
.replace(/[^a-z0-9_-]+/g, '-')
|
|
37
|
+
.replace(/^-+|-+$/g, '');
|
|
38
|
+
return normalized || fallback;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function createJobDir(toolName, filenameBase) {
|
|
42
|
+
const prefix = normalizeFilenameBase(toolName, 'tool');
|
|
43
|
+
const suffix = normalizeFilenameBase(filenameBase, 'output');
|
|
44
|
+
const jobId = `${Date.now()}-${crypto.randomBytes(4).toString('hex')}-${suffix}`;
|
|
45
|
+
return ensureDir(path.join(TOOLING_ROOT, prefix, jobId));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function resolveRepoBinary(...segments) {
|
|
49
|
+
const parts = [...segments];
|
|
50
|
+
if (process.platform === 'win32' && parts.length > 0 && !parts[parts.length - 1].endsWith('.cmd')) {
|
|
51
|
+
parts[parts.length - 1] = `${parts[parts.length - 1]}.cmd`;
|
|
52
|
+
}
|
|
53
|
+
return path.join(APP_DIR, 'node_modules', '.bin', ...parts);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function assertExistingFile(filePath, label = 'file') {
|
|
57
|
+
const absolutePath = path.resolve(String(filePath || ''));
|
|
58
|
+
if (!fs.existsSync(absolutePath)) {
|
|
59
|
+
throw new Error(`${label} does not exist: ${absolutePath}`);
|
|
60
|
+
}
|
|
61
|
+
const stat = fs.statSync(absolutePath);
|
|
62
|
+
if (!stat.isFile()) {
|
|
63
|
+
throw new Error(`${label} is not a file: ${absolutePath}`);
|
|
64
|
+
}
|
|
65
|
+
return absolutePath;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function writeTextFile(filePath, content) {
|
|
69
|
+
ensureDir(path.dirname(filePath));
|
|
70
|
+
fs.writeFileSync(filePath, String(content || ''), 'utf8');
|
|
71
|
+
return filePath;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function writeJsonFile(filePath, payload) {
|
|
75
|
+
writeTextFile(filePath, `${JSON.stringify(payload, null, 2)}\n`);
|
|
76
|
+
return filePath;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function copyAssetIntoJob(sourcePath, targetDir, preferredName = 'asset') {
|
|
80
|
+
const absolutePath = assertExistingFile(sourcePath, 'asset');
|
|
81
|
+
ensureDir(targetDir);
|
|
82
|
+
const extension = path.extname(absolutePath).toLowerCase();
|
|
83
|
+
const basename = normalizeFilenameBase(path.basename(absolutePath, extension), preferredName);
|
|
84
|
+
const filename = `${basename}-${crypto.randomBytes(3).toString('hex')}${extension}`;
|
|
85
|
+
const targetPath = path.join(targetDir, filename);
|
|
86
|
+
fs.copyFileSync(absolutePath, targetPath);
|
|
87
|
+
return {
|
|
88
|
+
sourcePath: absolutePath,
|
|
89
|
+
targetPath,
|
|
90
|
+
relativePath: filename,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function runCheckedCommand(executor, command, options = {}) {
|
|
95
|
+
if (!executor || typeof executor.execute !== 'function') {
|
|
96
|
+
throw new Error('CLI executor is unavailable for integrated tools.');
|
|
97
|
+
}
|
|
98
|
+
const result = await executor.execute(command, {
|
|
99
|
+
cwd: options.cwd,
|
|
100
|
+
timeout: options.timeout,
|
|
101
|
+
env: options.env,
|
|
102
|
+
});
|
|
103
|
+
if (result.exitCode !== 0) {
|
|
104
|
+
const stderr = String(result.stderr || '').trim();
|
|
105
|
+
const stdout = String(result.stdout || '').trim();
|
|
106
|
+
const details = stderr || stdout || `Command failed: ${command}`;
|
|
107
|
+
throw new Error(
|
|
108
|
+
options.errorPrefix
|
|
109
|
+
? `${options.errorPrefix} ${details}`.trim()
|
|
110
|
+
: details
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function inferArtifactExtension(filePath) {
|
|
117
|
+
return path.extname(String(filePath || '')).toLowerCase().replace(/^\./, '') || 'bin';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function inferContentType(filePath) {
|
|
121
|
+
const extension = path.extname(String(filePath || '')).toLowerCase();
|
|
122
|
+
switch (extension) {
|
|
123
|
+
case '.pdf': return 'application/pdf';
|
|
124
|
+
case '.ppt': return 'application/vnd.ms-powerpoint';
|
|
125
|
+
case '.pptx': return 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
|
|
126
|
+
case '.md': return 'text/markdown';
|
|
127
|
+
case '.png': return 'image/png';
|
|
128
|
+
case '.jpg':
|
|
129
|
+
case '.jpeg': return 'image/jpeg';
|
|
130
|
+
case '.svg': return 'image/svg+xml';
|
|
131
|
+
case '.mp4': return 'video/mp4';
|
|
132
|
+
case '.mov': return 'video/quicktime';
|
|
133
|
+
case '.webm': return 'video/webm';
|
|
134
|
+
default: return 'application/octet-stream';
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function inferArtifactKind(filePath) {
|
|
139
|
+
const extension = path.extname(String(filePath || '')).toLowerCase();
|
|
140
|
+
if (['.ppt', '.pptx'].includes(extension)) return 'slides';
|
|
141
|
+
if (['.pdf', '.md'].includes(extension)) return 'document';
|
|
142
|
+
if (['.png', '.jpg', '.jpeg', '.svg'].includes(extension)) return 'image';
|
|
143
|
+
if (['.mp4', '.mov', '.webm'].includes(extension)) return 'video';
|
|
144
|
+
return 'artifact';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function createArtifactDescriptor(filePath, options = {}) {
|
|
148
|
+
const absolutePath = assertExistingFile(filePath, 'output');
|
|
149
|
+
const stat = fs.statSync(absolutePath);
|
|
150
|
+
return {
|
|
151
|
+
kind: options.kind || inferArtifactKind(absolutePath),
|
|
152
|
+
label: options.label || path.basename(absolutePath),
|
|
153
|
+
path: absolutePath,
|
|
154
|
+
mimeType: options.mimeType || inferContentType(absolutePath),
|
|
155
|
+
size: stat.size,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function promoteArtifactDescriptor(descriptor, artifactStore, userId) {
|
|
160
|
+
if (!artifactStore || userId == null || !descriptor?.path) {
|
|
161
|
+
return descriptor;
|
|
162
|
+
}
|
|
163
|
+
const extension = inferArtifactExtension(descriptor.path);
|
|
164
|
+
const allocation = artifactStore.allocateFile(userId, {
|
|
165
|
+
kind: descriptor.kind || inferArtifactKind(descriptor.path),
|
|
166
|
+
extension,
|
|
167
|
+
contentType: descriptor.mimeType || inferContentType(descriptor.path),
|
|
168
|
+
filenameBase: normalizeFilenameBase(descriptor.label || descriptor.kind || 'artifact', 'artifact'),
|
|
169
|
+
originalFilename: path.basename(descriptor.path),
|
|
170
|
+
metadata: {
|
|
171
|
+
sourceTool: 'integrated-media',
|
|
172
|
+
originalPath: descriptor.path,
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
fs.copyFileSync(descriptor.path, allocation.storagePath);
|
|
176
|
+
const finalized = artifactStore.finalizeFile(allocation.artifactId, allocation.storagePath);
|
|
177
|
+
return {
|
|
178
|
+
kind: descriptor.kind || inferArtifactKind(descriptor.path),
|
|
179
|
+
label: descriptor.label || path.basename(descriptor.path),
|
|
180
|
+
url: finalized.url,
|
|
181
|
+
mimeType: descriptor.mimeType || inferContentType(descriptor.path),
|
|
182
|
+
size: finalized.byteSize,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
module.exports = {
|
|
187
|
+
copyAssetIntoJob,
|
|
188
|
+
createArtifactDescriptor,
|
|
189
|
+
createJobDir,
|
|
190
|
+
ensureDir,
|
|
191
|
+
ensureRepoNodeModulesLink,
|
|
192
|
+
normalizeFilenameBase,
|
|
193
|
+
promoteArtifactDescriptor,
|
|
194
|
+
resolveRepoBinary,
|
|
195
|
+
runCheckedCommand,
|
|
196
|
+
shellEscape,
|
|
197
|
+
writeJsonFile,
|
|
198
|
+
writeTextFile,
|
|
199
|
+
};
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const {
|
|
6
|
+
copyAssetIntoJob,
|
|
7
|
+
createArtifactDescriptor,
|
|
8
|
+
createJobDir,
|
|
9
|
+
ensureDir,
|
|
10
|
+
ensureRepoNodeModulesLink,
|
|
11
|
+
normalizeFilenameBase,
|
|
12
|
+
promoteArtifactDescriptor,
|
|
13
|
+
resolveRepoBinary,
|
|
14
|
+
runCheckedCommand,
|
|
15
|
+
shellEscape,
|
|
16
|
+
writeTextFile,
|
|
17
|
+
} = require('./shared');
|
|
18
|
+
|
|
19
|
+
const SLIDEV_BIN = resolveRepoBinary('slidev');
|
|
20
|
+
const DEFAULT_EXPORT_FORMATS = ['pdf'];
|
|
21
|
+
const ALLOWED_EXPORT_FORMATS = new Set(['pdf', 'pptx', 'png']);
|
|
22
|
+
const ALLOWED_LAYOUTS = new Set(['cover', 'intro', 'statement', 'quote', 'section', 'two-cols', 'default']);
|
|
23
|
+
|
|
24
|
+
function normalizeArray(value) {
|
|
25
|
+
return Array.isArray(value) ? value : [];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeExportFormats(value) {
|
|
29
|
+
const formats = normalizeArray(value)
|
|
30
|
+
.map((item) => String(item || '').trim().toLowerCase())
|
|
31
|
+
.filter((item) => ALLOWED_EXPORT_FORMATS.has(item));
|
|
32
|
+
return formats.length > 0 ? [...new Set(formats)] : [...DEFAULT_EXPORT_FORMATS];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function buildFrontmatter(args = {}) {
|
|
36
|
+
const lines = [
|
|
37
|
+
'---',
|
|
38
|
+
`theme: ${String(args.theme || 'default').trim() || 'default'}`,
|
|
39
|
+
];
|
|
40
|
+
if (args.title) lines.push(`title: ${JSON.stringify(String(args.title).trim())}`);
|
|
41
|
+
if (args.subtitle) lines.push(`info: ${JSON.stringify(String(args.subtitle).trim())}`);
|
|
42
|
+
lines.push('layout: cover');
|
|
43
|
+
lines.push('transition: fade-out');
|
|
44
|
+
lines.push('mdc: true');
|
|
45
|
+
lines.push('---');
|
|
46
|
+
return lines.join('\n');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function materializeSlideAsset(value, assetsDir) {
|
|
50
|
+
if (!value) return '';
|
|
51
|
+
const text = String(value).trim();
|
|
52
|
+
if (!text) return '';
|
|
53
|
+
if (/^https?:\/\//i.test(text)) {
|
|
54
|
+
return text;
|
|
55
|
+
}
|
|
56
|
+
const asset = copyAssetIntoJob(text, assetsDir, 'slide-asset');
|
|
57
|
+
return `./assets/${asset.relativePath}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function buildStructuredDeckMarkdown(args, jobDir) {
|
|
61
|
+
const slides = normalizeArray(args.slides);
|
|
62
|
+
if (slides.length === 0) {
|
|
63
|
+
throw new Error('generate_slide_deck requires a non-empty slides array or deck_markdown.');
|
|
64
|
+
}
|
|
65
|
+
const assetsDir = ensureDir(path.join(jobDir, 'assets'));
|
|
66
|
+
const sections = [buildFrontmatter(args)];
|
|
67
|
+
const coverTitle = String(args.title || 'Presentation').trim();
|
|
68
|
+
const coverSubtitle = String(args.subtitle || '').trim();
|
|
69
|
+
sections.push(`# ${coverTitle}${coverSubtitle ? `\n\n${coverSubtitle}` : ''}`);
|
|
70
|
+
|
|
71
|
+
for (const slide of slides) {
|
|
72
|
+
const title = String(slide?.title || '').trim();
|
|
73
|
+
const layout = ALLOWED_LAYOUTS.has(String(slide?.layout || '').trim()) ? String(slide.layout).trim() : 'default';
|
|
74
|
+
const body = String(slide?.body || '').trim();
|
|
75
|
+
const notes = String(slide?.notes || '').trim();
|
|
76
|
+
const bullets = normalizeArray(slide?.bullets)
|
|
77
|
+
.map((item) => String(item || '').trim())
|
|
78
|
+
.filter(Boolean);
|
|
79
|
+
const imageRef = materializeSlideAsset(slide?.image_path || slide?.image_url, assetsDir);
|
|
80
|
+
const slideLines = ['---'];
|
|
81
|
+
if (layout !== 'default') slideLines.push(`layout: ${layout}`);
|
|
82
|
+
if (slide?.className) slideLines.push(`class: ${JSON.stringify(String(slide.className).trim())}`);
|
|
83
|
+
slideLines.push('---');
|
|
84
|
+
if (title) slideLines.push(`# ${title}`);
|
|
85
|
+
if (body) slideLines.push('', body);
|
|
86
|
+
if (bullets.length > 0) {
|
|
87
|
+
if (!body) slideLines.push('');
|
|
88
|
+
slideLines.push(...bullets.map((item) => `- ${item}`));
|
|
89
|
+
}
|
|
90
|
+
if (imageRef) {
|
|
91
|
+
slideLines.push('', ``);
|
|
92
|
+
}
|
|
93
|
+
if (notes) {
|
|
94
|
+
slideLines.push('', `<!--\n${notes}\n-->`);
|
|
95
|
+
}
|
|
96
|
+
sections.push(slideLines.join('\n'));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return `${sections.join('\n\n')}\n`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function buildDeckMarkdown(args, jobDir) {
|
|
103
|
+
const rawMarkdown = String(args.deck_markdown || '').trim();
|
|
104
|
+
if (rawMarkdown) {
|
|
105
|
+
if (rawMarkdown.startsWith('---')) {
|
|
106
|
+
return `${rawMarkdown}\n`;
|
|
107
|
+
}
|
|
108
|
+
return `${buildFrontmatter(args)}\n\n${rawMarkdown}\n`;
|
|
109
|
+
}
|
|
110
|
+
return buildStructuredDeckMarkdown(args, jobDir);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function resolveExportPath(jobDir, filenameBase, format) {
|
|
114
|
+
if (format === 'png') {
|
|
115
|
+
return path.join(jobDir, `${filenameBase}-png`);
|
|
116
|
+
}
|
|
117
|
+
return path.join(jobDir, `${filenameBase}.${format}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function collectPngArtifacts(outputDir) {
|
|
121
|
+
if (!fs.existsSync(outputDir)) return [];
|
|
122
|
+
return fs.readdirSync(outputDir)
|
|
123
|
+
.filter((entry) => entry.toLowerCase().endsWith('.png'))
|
|
124
|
+
.sort()
|
|
125
|
+
.map((entry) => createArtifactDescriptor(path.join(outputDir, entry), {
|
|
126
|
+
kind: 'image',
|
|
127
|
+
label: entry,
|
|
128
|
+
mimeType: 'image/png',
|
|
129
|
+
}));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function generateSlideDeck(args, context = {}) {
|
|
133
|
+
if (!fs.existsSync(SLIDEV_BIN)) {
|
|
134
|
+
throw new Error('Slidev CLI is not installed.');
|
|
135
|
+
}
|
|
136
|
+
const filenameBase = normalizeFilenameBase(args.filename_base || args.title || 'slide-deck', 'slide-deck');
|
|
137
|
+
const jobDir = createJobDir('slidev', filenameBase);
|
|
138
|
+
ensureRepoNodeModulesLink(jobDir);
|
|
139
|
+
const markdownPath = path.join(jobDir, `${filenameBase}.md`);
|
|
140
|
+
const exportFormats = normalizeExportFormats(args.export_formats);
|
|
141
|
+
const deckMarkdown = buildDeckMarkdown(args, jobDir);
|
|
142
|
+
writeTextFile(markdownPath, deckMarkdown);
|
|
143
|
+
|
|
144
|
+
const executor = context.cliExecutor;
|
|
145
|
+
const commandOutputs = [];
|
|
146
|
+
for (const format of exportFormats) {
|
|
147
|
+
const outputPath = resolveExportPath(jobDir, filenameBase, format);
|
|
148
|
+
const command = [
|
|
149
|
+
shellEscape(SLIDEV_BIN),
|
|
150
|
+
'export',
|
|
151
|
+
shellEscape(markdownPath),
|
|
152
|
+
'--format',
|
|
153
|
+
shellEscape(format),
|
|
154
|
+
'--output',
|
|
155
|
+
shellEscape(outputPath),
|
|
156
|
+
].join(' ');
|
|
157
|
+
const result = await runCheckedCommand(executor, command, {
|
|
158
|
+
cwd: jobDir,
|
|
159
|
+
timeout: 15 * 60 * 1000,
|
|
160
|
+
errorPrefix: `Slidev export failed for ${format}.`,
|
|
161
|
+
});
|
|
162
|
+
commandOutputs.push({ format, result });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const descriptors = [
|
|
166
|
+
createArtifactDescriptor(markdownPath, {
|
|
167
|
+
kind: 'document',
|
|
168
|
+
label: path.basename(markdownPath),
|
|
169
|
+
mimeType: 'text/markdown',
|
|
170
|
+
}),
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
for (const format of exportFormats) {
|
|
174
|
+
const outputPath = resolveExportPath(jobDir, filenameBase, format);
|
|
175
|
+
if (format === 'png') {
|
|
176
|
+
descriptors.push(...collectPngArtifacts(outputPath));
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
descriptors.push(createArtifactDescriptor(outputPath, {
|
|
180
|
+
kind: format === 'pptx' ? 'slides' : 'document',
|
|
181
|
+
label: path.basename(outputPath),
|
|
182
|
+
}));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const promotedArtifacts = descriptors.map((descriptor) => (
|
|
186
|
+
promoteArtifactDescriptor(descriptor, context.artifactStore, context.userId)
|
|
187
|
+
));
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
success: true,
|
|
191
|
+
tool: 'generate_slide_deck',
|
|
192
|
+
title: String(args.title || '').trim() || null,
|
|
193
|
+
exportFormats,
|
|
194
|
+
artifacts: promotedArtifacts,
|
|
195
|
+
message: `Generated slide deck with ${exportFormats.join(', ')} output.`,
|
|
196
|
+
logs: commandOutputs.map((item) => ({
|
|
197
|
+
format: item.format,
|
|
198
|
+
durationMs: item.result.durationMs,
|
|
199
|
+
})),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
module.exports = {
|
|
204
|
+
generateSlideDeck,
|
|
205
|
+
};
|
|
@@ -9,6 +9,10 @@ const {
|
|
|
9
9
|
normalizeOutgoingMessageForPlatform,
|
|
10
10
|
} = require('../messaging/formatting_guides');
|
|
11
11
|
const { INTERIM_KINDS, normalizeInterimKind } = require('./interim');
|
|
12
|
+
const {
|
|
13
|
+
executeIntegratedTool,
|
|
14
|
+
getIntegratedToolDefinitions,
|
|
15
|
+
} = require('./integrated_tools');
|
|
12
16
|
|
|
13
17
|
function compactText(text, maxChars = 120) {
|
|
14
18
|
const str = String(text || '').replace(/\s+/g, ' ').trim();
|
|
@@ -1167,6 +1171,7 @@ function getAvailableTools(app, options = {}) {
|
|
|
1167
1171
|
required: ['prompt']
|
|
1168
1172
|
}
|
|
1169
1173
|
},
|
|
1174
|
+
...getIntegratedToolDefinitions(),
|
|
1170
1175
|
{
|
|
1171
1176
|
name: 'generate_table',
|
|
1172
1177
|
description: 'Format data into a markdown table. The resulting markdown will be returned to you. You MUST include it in your next message to the user so they can see it.',
|
|
@@ -1391,6 +1396,7 @@ async function executeTool(toolName, args, context, engine) {
|
|
|
1391
1396
|
const taskRuntime = () => app?.locals?.taskRuntime || engine.taskRuntime;
|
|
1392
1397
|
const rec = () => app?.locals?.recordingManager || null;
|
|
1393
1398
|
const widgets = () => app?.locals?.widgetService || null;
|
|
1399
|
+
const artifactStore = app?.locals?.artifactStore || null;
|
|
1394
1400
|
|
|
1395
1401
|
const integrationManager = integrations();
|
|
1396
1402
|
if (integrationManager) {
|
|
@@ -1415,6 +1421,16 @@ async function executeTool(toolName, args, context, engine) {
|
|
|
1415
1421
|
}
|
|
1416
1422
|
}
|
|
1417
1423
|
|
|
1424
|
+
const integratedToolResult = await executeIntegratedTool(toolName, args, {
|
|
1425
|
+
userId,
|
|
1426
|
+
agentId,
|
|
1427
|
+
cliExecutor: app?.locals?.cliExecutor || engine.cliExecutor || null,
|
|
1428
|
+
artifactStore,
|
|
1429
|
+
});
|
|
1430
|
+
if (integratedToolResult !== null) {
|
|
1431
|
+
return integratedToolResult;
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1418
1434
|
switch (toolName) {
|
|
1419
1435
|
case 'execute_command': {
|
|
1420
1436
|
const runtimeManager = runtime();
|
|
@@ -25,7 +25,7 @@ const { DesktopCompanionRegistry } = require('./desktop/registry');
|
|
|
25
25
|
const { DesktopProvider } = require('./desktop/provider');
|
|
26
26
|
const { ScreenRecorder } = require('./desktop/screenRecorder');
|
|
27
27
|
const { WearableService } = require('./wearable/service');
|
|
28
|
-
const {
|
|
28
|
+
const { getRuntimeValidation } = require('./runtime/validation');
|
|
29
29
|
const {
|
|
30
30
|
getErrorMessage,
|
|
31
31
|
runBackgroundTask,
|
|
@@ -487,8 +487,11 @@ async function startServices(app, io) {
|
|
|
487
487
|
const browserController = createBrowserController(app, artifactStore);
|
|
488
488
|
const androidController = createAndroidController(app, artifactStore);
|
|
489
489
|
const runtimeManager = createRuntimeManager(app, cliExecutor);
|
|
490
|
-
|
|
491
|
-
|
|
490
|
+
const runtimeValidation = getRuntimeValidation(runtimeManager);
|
|
491
|
+
registerLocal(app, 'runtimeValidation', runtimeValidation);
|
|
492
|
+
if (!runtimeValidation.ready) {
|
|
493
|
+
console.warn('[Services] Runtime validation is degraded:', runtimeValidation.issues.join(' '));
|
|
494
|
+
}
|
|
492
495
|
const skillRunner = await createSkillRunner(app, cliExecutor, runtimeManager);
|
|
493
496
|
const agentEngine = createAgentEngine(app, io, {
|
|
494
497
|
cliExecutor,
|