@empir3/empir3-bridge 0.3.21

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.
Files changed (62) hide show
  1. package/CHANGELOG.md +1531 -0
  2. package/CODE_OF_CONDUCT.md +9 -0
  3. package/CONTRIBUTING.md +75 -0
  4. package/LICENSE +21 -0
  5. package/README.md +464 -0
  6. package/SECURITY.md +130 -0
  7. package/assets/accuracy-lab.html +2639 -0
  8. package/assets/api-clis-real.jpg +0 -0
  9. package/assets/bridge-console-hero.jpg +0 -0
  10. package/assets/browser-privacy.svg +151 -0
  11. package/assets/demo-orchestration.svg +74 -0
  12. package/assets/desktop-select-region.jpg +0 -0
  13. package/assets/in-page-chat.gif +0 -0
  14. package/assets/orchestration-hero.svg +126 -0
  15. package/assets/social-preview.png +0 -0
  16. package/assets/zara-accent.png +0 -0
  17. package/build/bootstrap.js +548 -0
  18. package/build/build.js +680 -0
  19. package/build/payload-entry.js +649 -0
  20. package/build/payload-signing-pub.json +7 -0
  21. package/docs/AGENT_GUIDE.md +259 -0
  22. package/docs/RELEASE.md +106 -0
  23. package/docs/SAFETY.md +112 -0
  24. package/docs/TESTING.md +181 -0
  25. package/installer/server.js +231 -0
  26. package/installer/ui/app.js +278 -0
  27. package/installer/ui/index.html +24 -0
  28. package/installer/ui/styles.css +146 -0
  29. package/package.json +95 -0
  30. package/scripts/bootstrap-e2e.mjs +650 -0
  31. package/scripts/certify-bridge.mjs +636 -0
  32. package/scripts/check-companion-surface.mjs +118 -0
  33. package/scripts/extract-welcome.mjs +64 -0
  34. package/scripts/gh-route-handler-check.mjs +57 -0
  35. package/scripts/gh-wire-test.mjs +107 -0
  36. package/scripts/publish-downloads.mjs +180 -0
  37. package/scripts/smoke-all-tools.mjs +509 -0
  38. package/scripts/smoke-live-bridge.mjs +696 -0
  39. package/scripts/splice-welcome.mjs +63 -0
  40. package/scripts/welcome-body.txt +2733 -0
  41. package/src/anthropic-client.ts +192 -0
  42. package/src/bootstrap-exe.ts +69 -0
  43. package/src/bridge.ts +2444 -0
  44. package/src/chat.ts +345 -0
  45. package/src/cli-runner.ts +239 -0
  46. package/src/cli.ts +649 -0
  47. package/src/config.ts +199 -0
  48. package/src/desktop-overlay.ps1 +121 -0
  49. package/src/executable-resolver.ts +330 -0
  50. package/src/handlers/agy-imagegen.ts +179 -0
  51. package/src/handlers/github-cli.ts +399 -0
  52. package/src/handlers/higgsfield-cli.ts +783 -0
  53. package/src/launch.js +337 -0
  54. package/src/mcp-server.ts +1265 -0
  55. package/src/pair-claim.ts +218 -0
  56. package/src/payload-daemon.ts +168 -0
  57. package/src/server.ts +21036 -0
  58. package/src/tool-defaults.ts +230 -0
  59. package/src/update-check.js +136 -0
  60. package/tray/build.py +76 -0
  61. package/tray/requirements.txt +2 -0
  62. package/tray/tray.py +1843 -0
@@ -0,0 +1,783 @@
1
+ /**
2
+ * Higgsfield CLI handler.
3
+ *
4
+ * Same mental model as cli-runner.ts: the user already pays / authenticates
5
+ * with Higgsfield; we shell out to their local `higgsfield` binary instead
6
+ * of being a Higgsfield API client. The bridge is a permission gate + tool
7
+ * surface, not an upstream integration.
8
+ *
9
+ * Spec lives at docs/handoff_bridge_higgsfield_cli.md. Verified facts encoded
10
+ * inline so a future maintainer doesn't re-probe the CLI.
11
+ *
12
+ * Pre-decided defaults (from the handoff doc — see header comment per item):
13
+ * #1 Result URL extraction — generic path-priority parser, no per-model
14
+ * upfront probing. Logs the full parsed JSON every call so shape
15
+ * surprises become a polish item.
16
+ * #2 Per-model param naming — handler is pass-through via `extra: {...}`.
17
+ * MCP-calling agent owns CLI flag naming, no translation here.
18
+ * #3 Concurrency — single-job FIFO queue at the handler level. Avoids
19
+ * CLI credential lock contention without per-model awareness.
20
+ * #4 Cost cap — none for v1. Per-tool permission (defaults OFF for
21
+ * _generate) + global execute gate carry the safety load.
22
+ * #5 Token re-auth — regex-scan stderr for known auth-error patterns,
23
+ * surface as { stage: 'auth_expired', recoverable: true } so agents
24
+ * can prompt the user. No probe-by-invalidation.
25
+ * #6 Tray menu wording — see tray.py change; matches existing voice.
26
+ * #7 bridge-settings.json schema — generic `handlers: { [name]: { enabled } }`
27
+ * so future handlers (Replicate, Runway, Suno) drop in without
28
+ * migration. Gate logic mirrored in server.ts dispatcher.
29
+ * #8 Output contract — return BOTH parsed result and fetched bytes.
30
+ * Handler does spawn -> parse JSON -> extract URL -> HTTP GET ->
31
+ * save to ~/.empir3-bridge/artifacts/higgsfield/<stamp>-<uuid>.<ext>.
32
+ * #9 _list subcommand shape — probed once via `higgsfield generate --help`
33
+ * on first run; result cached in-process.
34
+ * #10 waitTimeoutMs ceiling — hard cap 20 min. Bridge ceiling, not CLI.
35
+ */
36
+
37
+ import { spawn } from 'child_process';
38
+ import { existsSync, writeFileSync, unlinkSync, mkdirSync, readFileSync } from 'fs';
39
+ import { join, extname } from 'path';
40
+ import { homedir } from 'os';
41
+ import { randomUUID } from 'crypto';
42
+
43
+ const STATUS_TIMEOUT_MS = 10 * 1000;
44
+ const LIST_TIMEOUT_MS = 30 * 1000;
45
+ const HELP_TIMEOUT_MS = 5 * 1000;
46
+ const GENERATE_TIMEOUT_MS = 25 * 60 * 1000; // matches --wait-timeout 20m + grace
47
+ const SIGTERM_GRACE_MS = 5000;
48
+ const MAX_BUFFER_BYTES = 8 * 1024 * 1024;
49
+ const ARTIFACT_FETCH_TIMEOUT_MS = 60 * 1000;
50
+ const MAX_PROMPT_LOG_CHARS = 80;
51
+
52
+ const ARTIFACT_DIR = join(homedir(), '.empir3-bridge', 'artifacts', 'higgsfield');
53
+
54
+ // Patterns scanned in stderr to translate raw CLI errors into structured
55
+ // recoverable stages. See default #5.
56
+ const AUTH_ERROR_PATTERNS: RegExp[] = [
57
+ /not authenticated/i,
58
+ /auth(?:enticate)?.{0,30}expired/i,
59
+ /token.{0,20}expired/i,
60
+ /token.{0,20}invalid/i,
61
+ /please run.*auth.*(?:login|signin|sign in)/i,
62
+ /unauthorized/i,
63
+ ];
64
+
65
+ const RATE_LIMIT_PATTERNS: RegExp[] = [
66
+ /rate.?limit/i,
67
+ /too many requests/i,
68
+ ];
69
+
70
+ const QUOTA_PATTERNS: RegExp[] = [
71
+ /quota/i,
72
+ /insufficient (?:credits|balance|funds)/i,
73
+ ];
74
+
75
+ // ── Binary discovery ────────────────────────────────────────────
76
+ //
77
+ // Verified install paths: `higgsfield` on PATH (POSIX + Windows when
78
+ // %APPDATA%\npm is on PATH), or %APPDATA%\npm\higgsfield.cmd (the npm-global
79
+ // shim) on Windows. Don't hunt anywhere else — if neither resolves, return
80
+ // null and let _status report installed:false cleanly.
81
+ function findHiggsfieldBinary(): string | null {
82
+ const onPath = whichSync('higgsfield');
83
+ if (onPath) return onPath;
84
+ if (process.platform === 'win32') {
85
+ const appdata = process.env.APPDATA || '';
86
+ if (appdata) {
87
+ const cmdShim = join(appdata, 'npm', 'higgsfield.cmd');
88
+ if (existsSync(cmdShim)) return cmdShim;
89
+ }
90
+ }
91
+ return null;
92
+ }
93
+
94
+ function whichSync(name: string): string | null {
95
+ try {
96
+ const cmd = process.platform === 'win32' ? `where ${name}` : `which ${name}`;
97
+ const { execSync } = require('child_process') as typeof import('child_process');
98
+ const out = execSync(cmd, { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8' });
99
+ const lines = out.split(/\r?\n/).map((l: string) => l.trim()).filter(Boolean);
100
+ if (!lines.length) return null;
101
+ if (process.platform === 'win32') {
102
+ const cmdShim = lines.find((l: string) => l.toLowerCase().endsWith('.cmd'));
103
+ if (cmdShim) return cmdShim;
104
+ }
105
+ return lines[0];
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
110
+
111
+ // ── Spawn helper ────────────────────────────────────────────────
112
+ //
113
+ // argv-only (no shell). Mirrors cli-runner.ts: SIGTERM, then SIGKILL after
114
+ // SIGTERM_GRACE_MS. Captures stdout/stderr as utf-8 strings with a hard
115
+ // MAX_BUFFER_BYTES ceiling so a runaway CLI doesn't OOM the bridge.
116
+ interface SpawnResult {
117
+ exitCode: number;
118
+ stdout: string;
119
+ stderr: string;
120
+ elapsedMs: number;
121
+ timedOut: boolean;
122
+ }
123
+
124
+ function spawnCapture(bin: string, argv: string[], timeoutMs: number): Promise<SpawnResult> {
125
+ return new Promise(resolve => {
126
+ const start = Date.now();
127
+ let child;
128
+ try {
129
+ const isWinShim = process.platform === 'win32' && /\.(cmd|bat)$/i.test(bin);
130
+ if (isWinShim) {
131
+ // Node 18.20+/20.12+ refuse to spawn .cmd directly on Windows
132
+ // (CVE-2024-27980). Spawn cmd.exe with the shim as an arg instead.
133
+ child = spawn('cmd.exe', ['/d', '/s', '/c', bin, ...argv], { windowsHide: true });
134
+ } else {
135
+ child = spawn(bin, argv, { windowsHide: true });
136
+ }
137
+ } catch (e: any) {
138
+ resolve({ exitCode: -1, stdout: '', stderr: `spawn failed: ${e?.message || String(e)}`, elapsedMs: Date.now() - start, timedOut: false });
139
+ return;
140
+ }
141
+
142
+ let stdout = '';
143
+ let stderr = '';
144
+ let timedOut = false;
145
+ let killed = false;
146
+ let stdoutBytes = 0;
147
+ let stderrBytes = 0;
148
+
149
+ const timer = setTimeout(() => {
150
+ timedOut = true;
151
+ killed = true;
152
+ try { child.kill('SIGTERM'); } catch {}
153
+ setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, SIGTERM_GRACE_MS);
154
+ }, timeoutMs);
155
+
156
+ child.stdout?.on('data', (chunk: Buffer) => {
157
+ stdoutBytes += chunk.length;
158
+ if (stdoutBytes <= MAX_BUFFER_BYTES) stdout += chunk.toString('utf-8');
159
+ });
160
+ child.stderr?.on('data', (chunk: Buffer) => {
161
+ stderrBytes += chunk.length;
162
+ if (stderrBytes <= MAX_BUFFER_BYTES) stderr += chunk.toString('utf-8');
163
+ });
164
+ child.on('error', (err: any) => {
165
+ stderr += `\n[spawn error] ${err?.message || String(err)}`;
166
+ });
167
+ child.on('close', (code: number | null) => {
168
+ clearTimeout(timer);
169
+ resolve({
170
+ exitCode: killed ? -1 : (code ?? -1),
171
+ stdout,
172
+ stderr,
173
+ elapsedMs: Date.now() - start,
174
+ timedOut,
175
+ });
176
+ });
177
+ });
178
+ }
179
+
180
+ // ── Error classification ────────────────────────────────────────
181
+
182
+ function parseHiggsfieldError(r: SpawnResult): { error: string; stage: string; recoverable: boolean } {
183
+ const text = `${r.stderr}\n${r.stdout}`;
184
+ if (r.timedOut) return { error: `higgsfield timed out after ${r.elapsedMs}ms`, stage: 'timeout', recoverable: true };
185
+ for (const p of AUTH_ERROR_PATTERNS) {
186
+ if (p.test(text)) return { error: 'higgsfield CLI is not authenticated — run `higgsfield auth login`', stage: 'auth_expired', recoverable: true };
187
+ }
188
+ for (const p of RATE_LIMIT_PATTERNS) {
189
+ if (p.test(text)) return { error: 'higgsfield CLI rate-limited', stage: 'rate_limit', recoverable: true };
190
+ }
191
+ for (const p of QUOTA_PATTERNS) {
192
+ if (p.test(text)) return { error: 'higgsfield CLI quota exhausted', stage: 'quota', recoverable: false };
193
+ }
194
+ const trimmed = (r.stderr.trim() || r.stdout.trim() || `higgsfield exited ${r.exitCode}`).slice(0, 2000);
195
+ return { error: trimmed, stage: 'cli_error', recoverable: false };
196
+ }
197
+
198
+ function lastNonEmptyLine(s: string): string {
199
+ const lines = s.split(/\r?\n/).filter(l => l.trim().length > 0);
200
+ return lines.length ? lines[lines.length - 1] : '';
201
+ }
202
+
203
+ function safeJsonParse(s: string): any {
204
+ try {
205
+ return JSON.parse(s);
206
+ } catch {
207
+ // CLIs sometimes emit a progress line before the final JSON; fall back
208
+ // to parsing only the last non-empty line.
209
+ try { return JSON.parse(lastNonEmptyLine(s)); } catch { return null; }
210
+ }
211
+ }
212
+
213
+ // Default #1 — generic URL extractor, returns first matching path.
214
+ // The Higgsfield CLI as of v0.1.40 actually returns an array of job
215
+ // objects with a top-level `result_url` field (not the nested shapes
216
+ // the original priority list assumed). Try the real-world shapes
217
+ // first, keep the speculative ones as fallbacks.
218
+ function extractArtifactUrl(parsed: any): string | null {
219
+ if (!parsed) return null;
220
+ // Array-of-jobs shape (verified against `higgsfield generate create
221
+ // z_image --json` on 2026-05-28).
222
+ const firstJob = Array.isArray(parsed) ? parsed[0] : null;
223
+ const candidates: any[] = [
224
+ firstJob?.result_url,
225
+ firstJob?.results?.[0]?.url,
226
+ firstJob?.results?.raw?.url,
227
+ parsed?.result_url,
228
+ parsed?.result?.url,
229
+ parsed?.result?.video?.url,
230
+ parsed?.result?.image?.url,
231
+ parsed?.results?.[0]?.url,
232
+ parsed?.results?.[0]?.result_url,
233
+ parsed?.jobs?.[0]?.result_url,
234
+ parsed?.jobs?.[0]?.results?.raw?.url,
235
+ parsed?.url,
236
+ parsed?.video?.url,
237
+ parsed?.image?.url,
238
+ ];
239
+ for (const c of candidates) {
240
+ if (typeof c === 'string' && /^https?:\/\//i.test(c)) return c;
241
+ }
242
+ return null;
243
+ }
244
+
245
+ function extFromUrl(url: string, contentType: string | null): string {
246
+ if (contentType) {
247
+ const ct = contentType.split(';')[0].trim().toLowerCase();
248
+ if (ct === 'video/mp4') return '.mp4';
249
+ if (ct === 'video/webm') return '.webm';
250
+ if (ct === 'image/jpeg') return '.jpg';
251
+ if (ct === 'image/png') return '.png';
252
+ if (ct === 'image/webp') return '.webp';
253
+ if (ct === 'image/gif') return '.gif';
254
+ }
255
+ try {
256
+ const u = new URL(url);
257
+ const ext = extname(u.pathname);
258
+ if (ext && ext.length <= 6) return ext;
259
+ } catch {}
260
+ return '.bin';
261
+ }
262
+
263
+ async function fetchToArtifact(url: string): Promise<{ path: string; bytes: number } | null> {
264
+ try {
265
+ const controller = new AbortController();
266
+ const timer = setTimeout(() => controller.abort(), ARTIFACT_FETCH_TIMEOUT_MS);
267
+ const res = await fetch(url, { signal: controller.signal });
268
+ clearTimeout(timer);
269
+ if (!res.ok) return null;
270
+ const buf = Buffer.from(await res.arrayBuffer());
271
+ const ext = extFromUrl(url, res.headers.get('content-type'));
272
+ mkdirSync(ARTIFACT_DIR, { recursive: true });
273
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
274
+ const path = join(ARTIFACT_DIR, `${stamp}-${randomUUID()}${ext}`);
275
+ writeFileSync(path, buf);
276
+ return { path, bytes: buf.length };
277
+ } catch {
278
+ return null;
279
+ }
280
+ }
281
+
282
+ // Default #3 — single-job FIFO queue.
283
+ let generateQueue: Promise<any> = Promise.resolve();
284
+ function enqueueGenerate<T>(fn: () => Promise<T>): Promise<T> {
285
+ const next = generateQueue.then(fn, fn);
286
+ generateQueue = next.then(() => undefined, () => undefined);
287
+ return next;
288
+ }
289
+
290
+ // Default #9 — probe `generate --help` once, cache for the process lifetime.
291
+ let listProbeCache: { subcommand: string[] | null; probedAt: number } | null = null;
292
+
293
+ async function resolveListInvocation(bin: string): Promise<string[] | null> {
294
+ if (listProbeCache) return listProbeCache.subcommand;
295
+ const help = await spawnCapture(bin, ['generate', '--help'], HELP_TIMEOUT_MS);
296
+ let subcommand: string[] | null = null;
297
+ if (help.exitCode === 0) {
298
+ const text = `${help.stdout}\n${help.stderr}`.toLowerCase();
299
+ if (/\blist\b/.test(text)) subcommand = ['generate', 'list'];
300
+ }
301
+ listProbeCache = { subcommand, probedAt: Date.now() };
302
+ return subcommand;
303
+ }
304
+
305
+ // ── Public handlers ─────────────────────────────────────────────
306
+
307
+ export interface HiggsfieldStatusResult {
308
+ installed: boolean;
309
+ version?: string | null;
310
+ authenticated?: boolean;
311
+ credentialsPath?: string;
312
+ }
313
+
314
+ export async function higgsfieldStatus(_params: Record<string, unknown> = {}): Promise<{ success: true; result: HiggsfieldStatusResult }> {
315
+ const bin = findHiggsfieldBinary();
316
+ if (!bin) {
317
+ return { success: true, result: { installed: false, authenticated: false } };
318
+ }
319
+ const version = await spawnCapture(bin, ['--version'], STATUS_TIMEOUT_MS);
320
+ const m = /^higgsfield\s+(\S+)/i.exec((version.stdout || '').trim());
321
+ const tok = await spawnCapture(bin, ['auth', 'token'], STATUS_TIMEOUT_MS);
322
+ // Auth iff exit 0 AND stdout matches /^hf_\S+\s*$/. Never log or surface
323
+ // the token value itself.
324
+ const authed = tok.exitCode === 0 && /^hf_\S+\s*$/.test(tok.stdout.trim());
325
+ return {
326
+ success: true,
327
+ result: {
328
+ installed: true,
329
+ version: m?.[1] || null,
330
+ authenticated: authed,
331
+ // Surfaced for user reference only — handler never opens it.
332
+ credentialsPath: join(homedir(), '.config', 'higgsfield', 'credentials.json'),
333
+ },
334
+ };
335
+ }
336
+
337
+ export interface HiggsfieldGenerateParams {
338
+ model: string;
339
+ prompt: string;
340
+ image?: string | Buffer | { data?: string; path?: string };
341
+ extra?: Record<string, string | number | boolean>;
342
+ waitTimeoutMs?: number;
343
+ }
344
+
345
+ export async function higgsfieldGenerate(params: HiggsfieldGenerateParams): Promise<any> {
346
+ if (!params || typeof params.model !== 'string' || !params.model.trim()) {
347
+ return { success: false, error: 'higgsfield_generate: `model` is required' };
348
+ }
349
+ if (typeof params.prompt !== 'string' || !params.prompt.trim()) {
350
+ return { success: false, error: 'higgsfield_generate: `prompt` is required' };
351
+ }
352
+ const bin = findHiggsfieldBinary();
353
+ if (!bin) return { success: false, error: 'higgsfield CLI not installed' };
354
+
355
+ // Default #10 — hard-cap 20 min on the wait window.
356
+ const timeoutMin = Math.min(
357
+ Math.max(Math.floor((params.waitTimeoutMs || 20 * 60_000) / 60_000), 1),
358
+ 20,
359
+ );
360
+
361
+ return enqueueGenerate(async () => {
362
+ mkdirSync(ARTIFACT_DIR, { recursive: true });
363
+
364
+ let imagePath: string | null = null;
365
+ let tempImageToCleanup: string | null = null;
366
+ try {
367
+ if (params.image !== undefined && params.image !== null) {
368
+ const resolved = resolveImageInput(params.image);
369
+ if (resolved.tempPath) tempImageToCleanup = resolved.tempPath;
370
+ imagePath = resolved.path;
371
+ }
372
+
373
+ const extraArgs: string[] = [];
374
+ if (params.extra && typeof params.extra === 'object') {
375
+ for (const [k, v] of Object.entries(params.extra)) {
376
+ if (!/^[a-z0-9][a-z0-9_-]*$/i.test(k)) continue; // ignore obviously-bogus keys
377
+ extraArgs.push(`--${k}`, String(v));
378
+ }
379
+ }
380
+
381
+ const argv = [
382
+ 'generate', 'create', params.model,
383
+ '--prompt', params.prompt,
384
+ ...(imagePath ? ['--image', imagePath] : []),
385
+ '--wait',
386
+ '--wait-timeout', `${timeoutMin}m`,
387
+ '--wait-interval', '5s',
388
+ '--json',
389
+ ...extraArgs,
390
+ ];
391
+
392
+ const r = await spawnCapture(bin, argv, GENERATE_TIMEOUT_MS);
393
+ if (r.exitCode !== 0) {
394
+ const parsed = parseHiggsfieldError(r);
395
+ return {
396
+ success: false,
397
+ error: parsed.error,
398
+ stage: parsed.stage,
399
+ recoverable: parsed.recoverable,
400
+ durationMs: r.elapsedMs,
401
+ };
402
+ }
403
+
404
+ const parsed = safeJsonParse(r.stdout);
405
+ const url = extractArtifactUrl(parsed);
406
+ let artifactPath: string | null = null;
407
+ let artifactBytes: number | null = null;
408
+ if (url) {
409
+ const saved = await fetchToArtifact(url);
410
+ if (saved) {
411
+ artifactPath = saved.path;
412
+ artifactBytes = saved.bytes;
413
+ }
414
+ }
415
+
416
+ // Default #1 — log full parsed JSON shape so the user can see new
417
+ // result shapes and ask for a priority bump later. Truncate the
418
+ // prompt in input echoes to MAX_PROMPT_LOG_CHARS (acceptance #5).
419
+ const promptEcho = params.prompt.length > MAX_PROMPT_LOG_CHARS
420
+ ? params.prompt.slice(0, MAX_PROMPT_LOG_CHARS) + '…'
421
+ : params.prompt;
422
+ try {
423
+ console.error('[higgsfield] generate ok', JSON.stringify({
424
+ model: params.model,
425
+ promptPreview: promptEcho,
426
+ durationMs: r.elapsedMs,
427
+ urlFound: !!url,
428
+ resultKeys: parsed && typeof parsed === 'object' ? Object.keys(parsed) : null,
429
+ }));
430
+ } catch {}
431
+
432
+ return {
433
+ success: true,
434
+ result: {
435
+ raw: parsed,
436
+ url,
437
+ artifactPath,
438
+ artifactBytes,
439
+ durationMs: r.elapsedMs,
440
+ },
441
+ };
442
+ } finally {
443
+ if (tempImageToCleanup) {
444
+ try { unlinkSync(tempImageToCleanup); } catch {}
445
+ }
446
+ }
447
+ });
448
+ }
449
+
450
+ // ── empir3-channel lending bridge ───────────────────────────────
451
+ //
452
+ // The Empir3 server lends the user's local higgsfield CLI for image/video
453
+ // gen over the empir3 WebSocket (HiggsfieldClient.runCli on the server side).
454
+ // Wire contract (server ⇄ bridge), mirrors claude:cli / codex:cli:
455
+ //
456
+ // server → bridge: higgsfield:cli:gen {id, kind, prompt, model, params, input_image?, timeout_sec}
457
+ // bridge → server: higgsfield:cli:progress {id, status} (optional)
458
+ // higgsfield:cli:done {id, exit_code, mime_type, bytes_base64, duration_sec}
459
+ // higgsfield:cli:error {id, stage, error}
460
+ // server → bridge: higgsfield:cli:abort {id} (cancel — no mid-flight hook, acked)
461
+ //
462
+ // This is the ONLY thing that was missing for CLI-mode videogen: the local
463
+ // executeCommand path (higgsfield_generate) was wired, but the empir3 channel
464
+ // never routed higgsfield:cli:* — so server requests dead-ended and timed out
465
+ // as "upstream returned no video". generate_image worked only because its
466
+ // route resolves to a direct API provider (Imagen), never touching the bridge.
467
+ //
468
+ // Param fidelity: server sends model-native snake_case params (video:
469
+ // { aspect_ratio, duration }). We forward them verbatim as --flag value (no
470
+ // translation — per default #2 the caller owns flag naming). Note veo3_1
471
+ // constrains duration to 4/6/8; the caller must send a valid value.
472
+ function mimeForArtifact(path: string, kind?: string): string {
473
+ switch (extname(path).toLowerCase()) {
474
+ case '.mp4': return 'video/mp4';
475
+ case '.webm': return 'video/webm';
476
+ case '.mov': return 'video/quicktime';
477
+ case '.png': return 'image/png';
478
+ case '.jpg':
479
+ case '.jpeg': return 'image/jpeg';
480
+ case '.webp': return 'image/webp';
481
+ case '.gif': return 'image/gif';
482
+ default: return kind === 'video' ? 'video/mp4' : 'image/png';
483
+ }
484
+ }
485
+
486
+ // Per-model param schema cache (`higgsfield model get <model> --json` →
487
+ // params[]). Separate from modelsCache (that one is `model list`, no params).
488
+ const MODEL_PARAMS_TTL_MS = 5 * 60 * 1000;
489
+ const modelParamsCache = new Map<string, { at: number; params: any[] | null }>();
490
+
491
+ async function getModelParams(model: string): Promise<any[] | null> {
492
+ const cached = modelParamsCache.get(model);
493
+ if (cached && (Date.now() - cached.at) < MODEL_PARAMS_TTL_MS) return cached.params;
494
+ const bin = findHiggsfieldBinary();
495
+ if (!bin) return null;
496
+ try {
497
+ const r = await spawnCapture(bin, ['model', 'get', model, '--json'], LIST_TIMEOUT_MS);
498
+ const parsed = r.exitCode === 0 ? safeJsonParse(r.stdout) : null;
499
+ const params = parsed && Array.isArray(parsed.params) ? parsed.params : null;
500
+ modelParamsCache.set(model, { at: Date.now(), params });
501
+ return params;
502
+ } catch {
503
+ modelParamsCache.set(model, { at: Date.now(), params: null });
504
+ return null;
505
+ }
506
+ }
507
+
508
+ // Snap caller params to what the model actually accepts, using the live schema.
509
+ // The server clamps videogen duration to 1-15 and sends it raw, but each model
510
+ // constrains it differently (veo3_1 duration ∈ {4,6,8}, aspect_ratio ∈
511
+ // {16:9,9:16}; kling3_0 duration is a free integer). Without this, a 5s veo3_1
512
+ // request — or a 1:1 aspect — CLI-errors. Rules: drop params the model doesn't
513
+ // list (unknown --flags error the CLI); snap enum values (nearest for numeric
514
+ // enums, model default otherwise); round integer params. Best-effort — if the
515
+ // schema can't be read we pass params through so generation is never blocked.
516
+ async function normalizeParamsForModel(
517
+ model: string,
518
+ params: Record<string, unknown>,
519
+ ): Promise<Record<string, unknown>> {
520
+ const specs = await getModelParams(model);
521
+ if (!specs) return { ...params };
522
+ const byName = new Map<string, any>();
523
+ for (const s of specs) if (s && typeof s.name === 'string') byName.set(s.name, s);
524
+
525
+ const out: Record<string, unknown> = {};
526
+ for (const [key, value] of Object.entries(params)) {
527
+ if (value == null) continue;
528
+ const spec = byName.get(key);
529
+ if (!spec) {
530
+ console.error(`[higgsfield] drop unsupported param --${key} for ${model}`);
531
+ continue;
532
+ }
533
+ let v: unknown = value;
534
+ const enumVals: any[] | null = Array.isArray(spec.enum) && spec.enum.length ? spec.enum : null;
535
+ if (enumVals && !enumVals.map(String).includes(String(v))) {
536
+ const numericEnum = enumVals.every((e) => Number.isFinite(Number(e)));
537
+ const want = Number(v);
538
+ if (numericEnum && Number.isFinite(want)) {
539
+ // Snap to nearest; on a tie prefer the larger value (e.g. 5s → 6s).
540
+ let best = enumVals[0];
541
+ let bestD = Infinity;
542
+ for (const e of enumVals) {
543
+ const d = Math.abs(Number(e) - want);
544
+ if (d < bestD || (d === bestD && Number(e) > Number(best))) { best = e; bestD = d; }
545
+ }
546
+ v = best;
547
+ } else {
548
+ v = spec.default != null ? spec.default : enumVals[0];
549
+ }
550
+ console.error(`[higgsfield] snap --${key} ${String(value)} → ${String(v)} for ${model}`);
551
+ } else if (!enumVals && spec.type === 'integer') {
552
+ const n = Number(v);
553
+ if (Number.isFinite(n) && !Number.isInteger(n)) v = Math.round(n);
554
+ }
555
+ out[key] = v;
556
+ }
557
+ return out;
558
+ }
559
+
560
+ export async function handleHiggsfieldCliCommand(
561
+ action: string,
562
+ payload: any,
563
+ send: (eventType: string, eventPayload: any) => void,
564
+ ): Promise<void> {
565
+ const id = payload?.id || '';
566
+
567
+ // No mid-flight cancel hook (single-shot CLI --wait). Ack silently so the
568
+ // server's abort path is satisfied; the watchdog will reconcile.
569
+ if (action === 'abort') return;
570
+
571
+ if (action !== 'gen') {
572
+ send('higgsfield:cli:error', { id, stage: 'unknown_action', error: `higgsfield:cli:${action} is not supported` });
573
+ return;
574
+ }
575
+
576
+ const kind = typeof payload?.kind === 'string' ? payload.kind : undefined;
577
+ const model = typeof payload?.model === 'string' ? payload.model.trim() : '';
578
+ const prompt = typeof payload?.prompt === 'string' ? payload.prompt : '';
579
+ if (!model) { send('higgsfield:cli:error', { id, stage: 'bad_request', error: 'higgsfield:cli:gen missing model' }); return; }
580
+ if (!prompt) { send('higgsfield:cli:error', { id, stage: 'bad_request', error: 'higgsfield:cli:gen missing prompt' }); return; }
581
+
582
+ // Server params (model-native names) → CLI --flag value. Snap to the model's
583
+ // live schema first (duration enum, aspect_ratio enum, integer rounding,
584
+ // drop-unknowns), then keep only scalars.
585
+ const rawParams = payload?.params && typeof payload.params === 'object' ? payload.params : {};
586
+ const params = await normalizeParamsForModel(model, rawParams);
587
+ const extra: Record<string, string | number | boolean> = {};
588
+ for (const [k, v] of Object.entries(params)) {
589
+ if (v == null) continue;
590
+ if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') extra[k] = v;
591
+ }
592
+
593
+ // Optional input image for image-to-video / img2img — base64 { mime_type, data }.
594
+ let image: HiggsfieldGenerateParams['image'] | undefined;
595
+ if (payload?.input_image && typeof payload.input_image === 'object' && typeof payload.input_image.data === 'string') {
596
+ image = { data: payload.input_image.data };
597
+ }
598
+
599
+ const timeoutSec = Number(payload?.timeout_sec);
600
+ const waitTimeoutMs = Number.isFinite(timeoutSec) && timeoutSec > 0 ? timeoutSec * 1000 : undefined;
601
+
602
+ send('higgsfield:cli:progress', { id, status: 'spawning' });
603
+
604
+ let out: any;
605
+ try {
606
+ out = await higgsfieldGenerate({ model, prompt, image, extra, waitTimeoutMs });
607
+ } catch (e: any) {
608
+ send('higgsfield:cli:error', { id, stage: 'spawn', error: e?.message || String(e) });
609
+ return;
610
+ }
611
+
612
+ if (!out || out.success !== true) {
613
+ send('higgsfield:cli:error', {
614
+ id,
615
+ stage: out?.stage || 'generate_failed',
616
+ error: out?.error || 'higgsfield generate failed',
617
+ });
618
+ return;
619
+ }
620
+
621
+ const artifactPath: string | undefined = out.result?.artifactPath;
622
+ if (!artifactPath || !existsSync(artifactPath)) {
623
+ // CLI reported success but bytes weren't fetched — most often a result-URL
624
+ // shape we didn't recognize. Surface the URL so the server can fall back.
625
+ send('higgsfield:cli:error', {
626
+ id,
627
+ stage: 'no_artifact',
628
+ error: out.result?.url
629
+ ? `higgsfield generated but artifact fetch failed (url=${out.result.url})`
630
+ : 'higgsfield produced no downloadable artifact',
631
+ });
632
+ return;
633
+ }
634
+
635
+ let bytesBase64: string;
636
+ try {
637
+ bytesBase64 = readFileSync(artifactPath).toString('base64');
638
+ } catch (e: any) {
639
+ send('higgsfield:cli:error', { id, stage: 'read_artifact', error: e?.message || 'failed to read artifact' });
640
+ return;
641
+ }
642
+
643
+ send('higgsfield:cli:done', {
644
+ id,
645
+ exit_code: 0,
646
+ mime_type: mimeForArtifact(artifactPath, kind),
647
+ bytes_base64: bytesBase64,
648
+ duration_sec: Number(extra.duration) || undefined,
649
+ duration_ms: out.result?.durationMs,
650
+ });
651
+ }
652
+
653
+ export interface HiggsfieldListParams {
654
+ limit?: number;
655
+ }
656
+
657
+ export async function higgsfieldList(params: HiggsfieldListParams = {}): Promise<any> {
658
+ const bin = findHiggsfieldBinary();
659
+ if (!bin) return { success: false, error: 'higgsfield CLI not installed' };
660
+
661
+ const subcommand = await resolveListInvocation(bin);
662
+ if (!subcommand) {
663
+ return {
664
+ success: false,
665
+ error: 'higgsfield generate --help did not advertise a `list` subcommand on this CLI version',
666
+ stage: 'unsupported',
667
+ recoverable: false,
668
+ };
669
+ }
670
+
671
+ // The higgsfield CLI (v0.1.40) `generate list` does NOT accept a `--limit`
672
+ // flag — passing it errors with `unknown flag: --limit`. So we never forward
673
+ // it; instead we fetch the full list and trim client-side after parsing.
674
+ const argv = [...subcommand, '--json'];
675
+
676
+ const r = await spawnCapture(bin, argv, LIST_TIMEOUT_MS);
677
+ if (r.exitCode !== 0) {
678
+ const parsed = parseHiggsfieldError(r);
679
+ return { success: false, error: parsed.error, stage: parsed.stage, recoverable: parsed.recoverable };
680
+ }
681
+ let parsed = safeJsonParse(r.stdout);
682
+ const limit = (typeof params?.limit === 'number' && Number.isFinite(params.limit) && params.limit > 0)
683
+ ? Math.floor(params.limit)
684
+ : null;
685
+ if (limit !== null && Array.isArray(parsed)) {
686
+ parsed = parsed.slice(0, limit);
687
+ }
688
+ return { success: true, result: { raw: parsed ?? r.stdout.trim(), durationMs: r.elapsedMs } };
689
+ }
690
+
691
+ export interface HiggsfieldModelsParams {
692
+ type?: string; // 'image' | 'video' | 'text' (optional filter)
693
+ }
694
+
695
+ // Live model discovery so a controlling LLM never has to guess a job_set_type.
696
+ // `higgsfield model list --json` is the source of truth (the catalog changes as
697
+ // Higgsfield adds models), filtered optionally by media type. Cached per-process
698
+ // (~5 min) — the list is stable within a session and the CLI call is ~1s.
699
+ const modelsCache = new Map<string, { at: number; data: any[] }>();
700
+ const MODELS_TTL_MS = 5 * 60 * 1000;
701
+
702
+ export async function higgsfieldModels(params: HiggsfieldModelsParams = {}): Promise<any> {
703
+ const bin = findHiggsfieldBinary();
704
+ if (!bin) return { success: false, error: 'higgsfield CLI not installed' };
705
+ const typeRaw = typeof params?.type === 'string' ? params.type.toLowerCase().trim() : '';
706
+ const type = ['image', 'video', 'text'].includes(typeRaw) ? typeRaw : '';
707
+ const cacheKey = type || 'all';
708
+ const cached = modelsCache.get(cacheKey);
709
+ if (cached && (Date.now() - cached.at) < MODELS_TTL_MS) {
710
+ return { success: true, result: { models: cached.data, type: type || 'all', count: cached.data.length, cached: true } };
711
+ }
712
+ const argv = ['model', 'list', '--json'];
713
+ if (type) argv.splice(2, 0, `--${type}`); // -> model list --image --json
714
+ const r = await spawnCapture(bin, argv, LIST_TIMEOUT_MS);
715
+ if (r.exitCode !== 0) {
716
+ const parsed = parseHiggsfieldError(r);
717
+ return { success: false, error: parsed.error, stage: parsed.stage, recoverable: parsed.recoverable };
718
+ }
719
+ const parsed = safeJsonParse(r.stdout);
720
+ const models = Array.isArray(parsed)
721
+ ? parsed
722
+ .map((m: any) => ({ job_set_type: m.job_set_type, name: m.display_name, type: m.type }))
723
+ .filter((m: any) => m.job_set_type)
724
+ : [];
725
+ modelsCache.set(cacheKey, { at: Date.now(), data: models });
726
+ return { success: true, result: { models, type: type || 'all', count: models.length } };
727
+ }
728
+
729
+ // ── Helpers ─────────────────────────────────────────────────────
730
+
731
+ function resolveImageInput(input: HiggsfieldGenerateParams['image']): { path: string | null; tempPath: string | null } {
732
+ if (input == null) return { path: null, tempPath: null };
733
+ if (Buffer.isBuffer(input)) return writeTempImage(input);
734
+
735
+ if (typeof input === 'string') {
736
+ // Heuristic: if it's a real path on disk, use it directly. Otherwise treat
737
+ // it as base64 image bytes (common JSON-transport encoding from agents).
738
+ if (existsSync(input)) return { path: input, tempPath: null };
739
+ try {
740
+ const buf = Buffer.from(stripDataUri(input), 'base64');
741
+ if (buf.length > 0) return writeTempImage(buf);
742
+ } catch {}
743
+ return { path: null, tempPath: null };
744
+ }
745
+
746
+ if (typeof input === 'object') {
747
+ if (typeof input.path === 'string' && input.path && existsSync(input.path)) {
748
+ return { path: input.path, tempPath: null };
749
+ }
750
+ if (typeof input.data === 'string' && input.data) {
751
+ try {
752
+ const buf = Buffer.from(stripDataUri(input.data), 'base64');
753
+ if (buf.length > 0) return writeTempImage(buf);
754
+ } catch {}
755
+ }
756
+ }
757
+ return { path: null, tempPath: null };
758
+ }
759
+
760
+ function stripDataUri(s: string): string {
761
+ const m = /^data:[^;]+;base64,(.*)$/i.exec(s.trim());
762
+ return m ? m[1] : s.trim();
763
+ }
764
+
765
+ function writeTempImage(buf: Buffer): { path: string; tempPath: string } {
766
+ mkdirSync(ARTIFACT_DIR, { recursive: true });
767
+ // Default extension is .bin; auto-upload doesn't care. Pick by magic for
768
+ // common formats so the CLI's content-type inference (if any) is happy.
769
+ const ext = sniffExt(buf);
770
+ const path = join(ARTIFACT_DIR, `upload-${randomUUID()}${ext}`);
771
+ writeFileSync(path, buf);
772
+ return { path, tempPath: path };
773
+ }
774
+
775
+ function sniffExt(buf: Buffer): string {
776
+ if (buf.length >= 4) {
777
+ if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return '.jpg';
778
+ if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) return '.png';
779
+ if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46) return '.gif';
780
+ if (buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46) return '.webp';
781
+ }
782
+ return '.bin';
783
+ }