@agenticmail/enterprise 0.5.208 → 0.5.210

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agenticmail/enterprise",
3
- "version": "0.5.208",
3
+ "version": "0.5.210",
4
4
  "description": "AgenticMail Enterprise — cloud-hosted AI agent identity, email, auth & compliance for organizations",
5
5
  "type": "module",
6
6
  "bin": {
@@ -43,7 +43,7 @@ export const I = {
43
43
  link: () => h('svg', S, h('path', { d: 'M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71' }), h('path', { d: 'M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71' })),
44
44
  folder: () => h('svg', S, h('path', { d: 'M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z' })),
45
45
  globe: () => h('svg', S, h('circle', { cx: 12, cy: 12, r: 10 }), h('line', { x1: 2, y1: 12, x2: 22, y2: 12 }), h('path', { d: 'M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z' })),
46
- workflow: () => h('svg', S, h('rect', { x: 3, y: 3, width: 6, height: 6, rx: 1 }), h('rect', { x: 15, y: 3, width: 6, height: 6, rx: 1 }), h('rect', { x: 9, y: 15, width: 6, height: 6, rx: 1 }), h('line', { x1: 9, y1: 6, x2: 15, y2: 6 }), h('line', { x1: 12, y1: 9, x2: 12, y2: 15 }), h('line', { x1: 6, y1: 9, x2: 6, y2: 18 }), h('line', { x1: 6, y1: 18, x2: 9, y2: 18 }), h('line', { x1: 18, y1: 9, x2: 18, y2: 18 }), h('line', { x1: 18, y1: 18, x2: 15, y2: 18 })),
46
+ workflow: () => h('svg', Object.assign({}, S, { width: 16, height: 16 }), h('circle', { cx: 4, cy: 12, r: 2.5, fill: 'currentColor', stroke: 'none' }), h('circle', { cx: 12, cy: 7, r: 2.5, fill: 'currentColor', stroke: 'none' }), h('circle', { cx: 12, cy: 17, r: 2.5, fill: 'currentColor', stroke: 'none' }), h('circle', { cx: 20, cy: 12, r: 2.5, fill: 'currentColor', stroke: 'none' }), h('path', { d: 'M6.5 11L9.5 8M6.5 13L9.5 16M14.5 8L17.5 11M14.5 16L17.5 13', stroke: 'currentColor', strokeWidth: 1.5, fill: 'none' })),
47
47
  orgChart: () => h('svg', S, h('rect', { x: 8, y: 2, width: 8, height: 5, rx: 1 }), h('rect', { x: 1, y: 17, width: 8, height: 5, rx: 1 }), h('rect', { x: 15, y: 17, width: 8, height: 5, rx: 1 }), h('line', { x1: 12, y1: 7, x2: 12, y2: 12 }), h('line', { x1: 5, y1: 12, x2: 19, y2: 12 }), h('line', { x1: 5, y1: 12, x2: 5, y2: 17 }), h('line', { x1: 19, y1: 12, x2: 19, y2: 17 })),
48
48
  terminal: () => h('svg', S, h('polyline', { points: '4 17 10 11 4 5' }), h('line', { x1: 12, y1: 19, x2: 20, y2: 19 })),
49
49
  chart: () => h('svg', S, h('line', { x1: 18, y1: 20, x2: 18, y2: 10 }), h('line', { x1: 12, y1: 20, x2: 12, y2: 4 }), h('line', { x1: 6, y1: 20, x2: 6, y2: 14 })),
@@ -205,6 +205,380 @@ function RoutingRowEditor(props) {
205
205
  );
206
206
  }
207
207
 
208
+ // ─── Model Cost Estimates (per 1K tokens, approximate USD) ───
209
+
210
+ var MODEL_COSTS = {
211
+ 'claude-opus-4-20250514': { input: 0.015, output: 0.075 },
212
+ 'claude-sonnet-4-20250514': { input: 0.003, output: 0.015 },
213
+ 'claude-haiku-3-20250414': { input: 0.00025, output: 0.00125 },
214
+ 'gpt-4o': { input: 0.005, output: 0.015 },
215
+ 'gpt-4o-mini': { input: 0.00015, output: 0.0006 },
216
+ 'gemini-2.0-flash': { input: 0.0001, output: 0.0004 },
217
+ 'gemini-2.5-pro': { input: 0.00125, output: 0.01 },
218
+ };
219
+
220
+ function estimateHeartbeatCost(modelId, intervalMin, tokensPerBeat) {
221
+ tokensPerBeat = tokensPerBeat || 3000; // ~3K tokens per heartbeat round-trip
222
+ var key = Object.keys(MODEL_COSTS).find(function(k) { return modelId && modelId.indexOf(k) !== -1; });
223
+ var cost = key ? MODEL_COSTS[key] : { input: 0.003, output: 0.015 }; // default to sonnet pricing
224
+ var beatsPerDay = (24 * 60) / intervalMin;
225
+ var dailyCost = beatsPerDay * tokensPerBeat * ((cost.input + cost.output) / 2) / 1000;
226
+ return { beatsPerDay: Math.round(beatsPerDay), dailyCost: dailyCost, monthlyCost: dailyCost * 30 };
227
+ }
228
+
229
+ // ─── Model Fallback Card ───
230
+
231
+ function ModelFallbackCard(props) {
232
+ var config = props.config;
233
+ var saving = props.saving;
234
+ var providers = props.providers;
235
+ var saveUpdates = props.saveUpdates;
236
+
237
+ var fb = config.modelFallback || {};
238
+ var _editing = useState(false);
239
+ var editing = _editing[0]; var setEditing = _editing[1];
240
+ var _form = useState({});
241
+ var form = _form[0]; var setForm = _form[1];
242
+
243
+ function startEdit() {
244
+ setForm({
245
+ enabled: fb.enabled !== false,
246
+ fallbacks: (fb.fallbacks || []).slice(),
247
+ maxRetries: fb.maxRetries || 2,
248
+ retryDelayMs: fb.retryDelayMs || 1000,
249
+ });
250
+ setEditing(true);
251
+ }
252
+
253
+ function addFallback() {
254
+ setForm(function(f) { return Object.assign({}, f, { fallbacks: f.fallbacks.concat(['']) }); });
255
+ }
256
+
257
+ function removeFallback(idx) {
258
+ setForm(function(f) {
259
+ var next = f.fallbacks.slice();
260
+ next.splice(idx, 1);
261
+ return Object.assign({}, f, { fallbacks: next });
262
+ });
263
+ }
264
+
265
+ function updateFallback(idx, val) {
266
+ setForm(function(f) {
267
+ var next = f.fallbacks.slice();
268
+ next[idx] = val;
269
+ return Object.assign({}, f, { fallbacks: next });
270
+ });
271
+ }
272
+
273
+ function save() {
274
+ saveUpdates({
275
+ modelFallback: {
276
+ enabled: form.enabled,
277
+ fallbacks: form.fallbacks.filter(Boolean),
278
+ maxRetries: parseInt(form.maxRetries) || 2,
279
+ retryDelayMs: parseInt(form.retryDelayMs) || 1000,
280
+ }
281
+ }, function() { setEditing(false); });
282
+ }
283
+
284
+ var configuredProviders = providers.filter(function(p) { return p.configured; });
285
+
286
+ // Build flat list of all models from all configured providers for the dropdown
287
+ var _allModels = useState([]);
288
+ var allModels = _allModels[0]; var setAllModels = _allModels[1];
289
+ useEffect(function() {
290
+ if (!configuredProviders.length) return;
291
+ Promise.all(configuredProviders.map(function(p) {
292
+ return apiCall('/providers/' + p.id + '/models').then(function(d) {
293
+ return (d.models || []).map(function(m) { return { id: p.id + '/' + (m.id || m.name), label: p.name + ' / ' + (m.name || m.id) }; });
294
+ }).catch(function() { return []; });
295
+ })).then(function(results) {
296
+ setAllModels([].concat.apply([], results));
297
+ });
298
+ }, [providers]);
299
+
300
+ return h('div', { className: 'card', style: { padding: 20, marginBottom: 20 } },
301
+ h(CardHeader, {
302
+ title: 'Backup Model Providers',
303
+ help: h(HelpButton, { label: 'Backup Model Providers' },
304
+ h('p', null, 'Configure fallback models that activate automatically when the primary model fails (rate limits, outages, auth errors).'),
305
+ h('h4', { style: _h4 }, 'How It Works'),
306
+ h('ul', { style: _ul },
307
+ h('li', null, 'When the primary model returns an error, the system tries the next model in the chain.'),
308
+ h('li', null, h('strong', null, 'Rate limit / overload errors'), ' — retries the same model first (up to Max Retries), then moves to the next fallback.'),
309
+ h('li', null, h('strong', null, 'Auth / invalid model errors'), ' — skips immediately to the next fallback (no retry).'),
310
+ h('li', null, 'The chain is tried in order from top to bottom.')
311
+ ),
312
+ h('h4', { style: _h4 }, 'Settings'),
313
+ h('ul', { style: _ul },
314
+ h('li', null, h('strong', null, 'Max Retries'), ' — How many times to retry a single model before moving to the next one.'),
315
+ h('li', null, h('strong', null, 'Retry Delay'), ' — Wait time (ms) between retries. Increases with each attempt (exponential backoff).')
316
+ ),
317
+ h('div', { style: _tip }, h('strong', null, 'Tip: '), 'A good chain mixes providers — e.g., Anthropic primary → OpenAI fallback → Google fallback. This protects against single-provider outages.')
318
+ ),
319
+ editing: editing,
320
+ saving: saving,
321
+ onEdit: startEdit,
322
+ onSave: save,
323
+ onCancel: function() { setEditing(false); }
324
+ }),
325
+
326
+ editing
327
+ ? h(Fragment, null,
328
+ // Enable toggle
329
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 } },
330
+ h('label', { style: { display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 13 } },
331
+ h('input', { type: 'checkbox', checked: form.enabled, onChange: function(e) { setForm(function(f) { return Object.assign({}, f, { enabled: e.target.checked }); }); } }),
332
+ 'Enable model fallback'
333
+ )
334
+ ),
335
+
336
+ form.enabled && h(Fragment, null,
337
+ // Fallback chain
338
+ h('label', { style: labelStyle }, 'Fallback Chain (in priority order)'),
339
+ h('div', { style: { display: 'grid', gap: 8, marginBottom: 16 } },
340
+ form.fallbacks.map(function(fb, idx) {
341
+ return h('div', { key: idx, style: { display: 'flex', gap: 8, alignItems: 'center' } },
342
+ h('span', { style: { fontSize: 11, color: 'var(--text-muted)', width: 20, textAlign: 'center', flexShrink: 0 } }, '#' + (idx + 1)),
343
+ allModels.length > 0
344
+ ? h('select', { style: Object.assign({}, inputStyle, { flex: 1, cursor: 'pointer' }), value: fb, onChange: function(e) { updateFallback(idx, e.target.value); } },
345
+ h('option', { value: '' }, '-- Select model --'),
346
+ allModels.map(function(m) { return h('option', { key: m.id, value: m.id }, m.label); })
347
+ )
348
+ : h('input', { style: Object.assign({}, inputStyle, { flex: 1 }), value: fb, placeholder: 'provider/model-id (e.g. openai/gpt-4o)', onChange: function(e) { updateFallback(idx, e.target.value); } }),
349
+ h('button', { className: 'btn btn-ghost btn-sm', style: { color: 'var(--danger)', flexShrink: 0 }, onClick: function() { removeFallback(idx); } }, '\u2715')
350
+ );
351
+ }),
352
+ h('button', { className: 'btn btn-ghost btn-sm', onClick: addFallback, style: { justifySelf: 'start' } }, '+ Add Fallback Model')
353
+ ),
354
+
355
+ // Settings row
356
+ h('div', { style: rowStyle },
357
+ h('div', { style: fieldGroupStyle },
358
+ h('label', { style: labelStyle }, 'Max Retries per Model'),
359
+ h('select', { style: Object.assign({}, inputStyle, { cursor: 'pointer' }), value: form.maxRetries, onChange: function(e) { setForm(function(f) { return Object.assign({}, f, { maxRetries: e.target.value }); }); } },
360
+ h('option', { value: 1 }, '1'),
361
+ h('option', { value: 2 }, '2 (recommended)'),
362
+ h('option', { value: 3 }, '3'),
363
+ h('option', { value: 5 }, '5')
364
+ )
365
+ ),
366
+ h('div', { style: fieldGroupStyle },
367
+ h('label', { style: labelStyle }, 'Retry Delay (ms)'),
368
+ h('select', { style: Object.assign({}, inputStyle, { cursor: 'pointer' }), value: form.retryDelayMs, onChange: function(e) { setForm(function(f) { return Object.assign({}, f, { retryDelayMs: e.target.value }); }); } },
369
+ h('option', { value: 500 }, '500ms'),
370
+ h('option', { value: 1000 }, '1,000ms (recommended)'),
371
+ h('option', { value: 2000 }, '2,000ms'),
372
+ h('option', { value: 5000 }, '5,000ms')
373
+ )
374
+ )
375
+ )
376
+ )
377
+ )
378
+ : h(Fragment, null,
379
+ fb.enabled === false
380
+ ? h('span', { style: { fontSize: 13, color: 'var(--text-muted)' } }, 'Model fallback is disabled.')
381
+ : (fb.fallbacks || []).length > 0
382
+ ? h(Fragment, null,
383
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 } },
384
+ h('span', { className: 'badge badge-success', style: { fontSize: 11 } }, 'Enabled'),
385
+ h('span', { style: { fontSize: 12, color: 'var(--text-muted)' } }, (fb.fallbacks.length) + ' fallback' + (fb.fallbacks.length > 1 ? 's' : '') + ' \u00B7 ' + (fb.maxRetries || 2) + ' retries \u00B7 ' + (fb.retryDelayMs || 1000) + 'ms delay')
386
+ ),
387
+ h('div', { style: { display: 'grid', gap: 6 } },
388
+ fb.fallbacks.map(function(m, i) {
389
+ return h('div', { key: i, style: { display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', background: 'var(--bg-secondary)', borderRadius: 'var(--radius)' } },
390
+ h('span', { style: { fontSize: 11, color: 'var(--text-muted)', width: 20 } }, '#' + (i + 1)),
391
+ h('span', { className: 'badge badge-info', style: { fontSize: 11, fontFamily: 'var(--font-mono)' } }, m.split('/').pop()),
392
+ h('span', { style: { fontSize: 11, color: 'var(--text-muted)' } }, m.split('/')[0])
393
+ );
394
+ })
395
+ )
396
+ )
397
+ : h('span', { style: { fontSize: 13, color: 'var(--text-muted)' } }, 'No fallback models configured \u2014 if the primary model fails, requests will error.')
398
+ )
399
+ );
400
+ }
401
+
402
+ // ─── Heartbeat Configuration Card ───
403
+
404
+ function HeartbeatCard(props) {
405
+ var config = props.config;
406
+ var saving = props.saving;
407
+ var modelObj = props.modelObj;
408
+ var modelStr = props.modelStr;
409
+ var saveUpdates = props.saveUpdates;
410
+
411
+ var hb = config.heartbeat || {};
412
+ var _editing = useState(false);
413
+ var editing = _editing[0]; var setEditing = _editing[1];
414
+ var _form = useState({});
415
+ var form = _form[0]; var setForm = _form[1];
416
+
417
+ function startEdit() {
418
+ setForm({
419
+ enabled: hb.enabled !== false,
420
+ intervalMinutes: hb.intervalMinutes || 30,
421
+ activeHoursStart: hb.activeHoursStart != null ? hb.activeHoursStart : 8,
422
+ activeHoursEnd: hb.activeHoursEnd != null ? hb.activeHoursEnd : 23,
423
+ prompt: hb.prompt || '',
424
+ tokensPerBeat: hb.tokensPerBeat || 3000,
425
+ });
426
+ setEditing(true);
427
+ }
428
+
429
+ function save() {
430
+ saveUpdates({
431
+ heartbeat: {
432
+ enabled: form.enabled,
433
+ intervalMinutes: parseInt(form.intervalMinutes) || 30,
434
+ activeHoursStart: parseInt(form.activeHoursStart),
435
+ activeHoursEnd: parseInt(form.activeHoursEnd),
436
+ prompt: form.prompt || '',
437
+ tokensPerBeat: parseInt(form.tokensPerBeat) || 3000,
438
+ }
439
+ }, function() { setEditing(false); });
440
+ }
441
+
442
+ var modelId = modelStr || modelObj.modelId || '';
443
+ var est = estimateHeartbeatCost(modelId, editing ? (parseInt(form.intervalMinutes) || 30) : (hb.intervalMinutes || 30), editing ? (parseInt(form.tokensPerBeat) || 3000) : (hb.tokensPerBeat || 3000));
444
+
445
+ var INTERVAL_OPTIONS = [
446
+ { value: 5, label: 'Every 5 minutes' },
447
+ { value: 10, label: 'Every 10 minutes' },
448
+ { value: 15, label: 'Every 15 minutes' },
449
+ { value: 30, label: 'Every 30 minutes (recommended)' },
450
+ { value: 60, label: 'Every 1 hour' },
451
+ { value: 120, label: 'Every 2 hours' },
452
+ { value: 360, label: 'Every 6 hours' },
453
+ { value: 720, label: 'Every 12 hours' },
454
+ { value: 1440, label: 'Once a day' },
455
+ ];
456
+
457
+ var hours = [];
458
+ for (var i = 0; i < 24; i++) { hours.push({ value: i, label: (i === 0 ? '12' : i > 12 ? '' + (i - 12) : '' + i) + ':00 ' + (i < 12 ? 'AM' : 'PM') }); }
459
+
460
+ return h('div', { className: 'card', style: { padding: 20, marginBottom: 20 } },
461
+ h(CardHeader, {
462
+ title: 'Heartbeat',
463
+ help: h(HelpButton, { label: 'Heartbeat Configuration' },
464
+ h('p', null, 'Heartbeats are periodic check-ins where the agent wakes up, checks for pending work (emails, calendar, notifications), and takes proactive action.'),
465
+ h('h4', { style: _h4 }, 'Settings'),
466
+ h('ul', { style: _ul },
467
+ h('li', null, h('strong', null, 'Interval'), ' — How often the agent checks in. Shorter = more responsive but more expensive.'),
468
+ h('li', null, h('strong', null, 'Active Hours'), ' — Only run heartbeats during these hours (agent\'s timezone). Saves cost overnight.'),
469
+ h('li', null, h('strong', null, 'Custom Prompt'), ' — Override the default heartbeat prompt. Use this to tell the agent what to check during heartbeats.'),
470
+ h('li', null, h('strong', null, 'Tokens per Beat'), ' — Estimated tokens consumed per heartbeat cycle (used for cost projection).')
471
+ ),
472
+ h('h4', { style: _h4 }, 'Cost Impact'),
473
+ h('p', null, 'Each heartbeat is a full LLM call. A 30-minute interval = 48 calls/day. The cost estimate below uses your current default model\'s pricing.'),
474
+ h('div', { style: _tip }, h('strong', null, 'Tip: '), 'Use a cheaper model for heartbeats via Model Routing (set the "Scheduling" context). A 30-min interval with Haiku costs ~$0.10/day vs ~$5/day with Opus.')
475
+ ),
476
+ editing: editing,
477
+ saving: saving,
478
+ onEdit: startEdit,
479
+ onSave: save,
480
+ onCancel: function() { setEditing(false); }
481
+ }),
482
+
483
+ // Cost estimate banner (always visible)
484
+ h('div', { style: { display: 'flex', gap: 16, padding: '12px 16px', background: 'var(--bg-secondary)', borderRadius: 'var(--radius)', marginBottom: 16, alignItems: 'center' } },
485
+ h('div', { style: { flex: 1 } },
486
+ h('div', { style: { fontSize: 11, color: 'var(--text-muted)', marginBottom: 2 } }, 'Estimated Cost'),
487
+ h('div', { style: { fontSize: 16, fontWeight: 700 } }, '$' + est.dailyCost.toFixed(2) + '/day'),
488
+ h('div', { style: { fontSize: 11, color: 'var(--text-muted)' } }, '\u2248 $' + est.monthlyCost.toFixed(2) + '/month')
489
+ ),
490
+ h('div', { style: { flex: 1 } },
491
+ h('div', { style: { fontSize: 11, color: 'var(--text-muted)', marginBottom: 2 } }, 'Beats/Day'),
492
+ h('div', { style: { fontSize: 16, fontWeight: 700 } }, '' + est.beatsPerDay)
493
+ ),
494
+ h('div', { style: { flex: 1 } },
495
+ h('div', { style: { fontSize: 11, color: 'var(--text-muted)', marginBottom: 2 } }, 'Model'),
496
+ h('div', { style: { fontSize: 12, fontFamily: 'var(--font-mono)' } }, modelId ? modelId.split('/').pop() : 'Not set')
497
+ )
498
+ ),
499
+
500
+ editing
501
+ ? h(Fragment, null,
502
+ // Enable toggle
503
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 } },
504
+ h('label', { style: { display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 13 } },
505
+ h('input', { type: 'checkbox', checked: form.enabled, onChange: function(e) { setForm(function(f) { return Object.assign({}, f, { enabled: e.target.checked }); }); } }),
506
+ 'Enable heartbeat'
507
+ )
508
+ ),
509
+
510
+ form.enabled && h(Fragment, null,
511
+ // Interval
512
+ h('div', { style: fieldGroupStyle },
513
+ h('label', { style: labelStyle }, 'Interval'),
514
+ h('select', { style: Object.assign({}, inputStyle, { cursor: 'pointer' }), value: form.intervalMinutes, onChange: function(e) { setForm(function(f) { return Object.assign({}, f, { intervalMinutes: e.target.value }); }); } },
515
+ INTERVAL_OPTIONS.map(function(o) { return h('option', { key: o.value, value: o.value }, o.label); })
516
+ )
517
+ ),
518
+
519
+ // Active hours
520
+ h('div', { style: rowStyle },
521
+ h('div', { style: fieldGroupStyle },
522
+ h('label', { style: labelStyle }, 'Active From'),
523
+ h('select', { style: Object.assign({}, inputStyle, { cursor: 'pointer' }), value: form.activeHoursStart, onChange: function(e) { setForm(function(f) { return Object.assign({}, f, { activeHoursStart: e.target.value }); }); } },
524
+ hours.map(function(hr) { return h('option', { key: hr.value, value: hr.value }, hr.label); })
525
+ )
526
+ ),
527
+ h('div', { style: fieldGroupStyle },
528
+ h('label', { style: labelStyle }, 'Active Until'),
529
+ h('select', { style: Object.assign({}, inputStyle, { cursor: 'pointer' }), value: form.activeHoursEnd, onChange: function(e) { setForm(function(f) { return Object.assign({}, f, { activeHoursEnd: e.target.value }); }); } },
530
+ hours.map(function(hr) { return h('option', { key: hr.value, value: hr.value }, hr.label); })
531
+ )
532
+ )
533
+ ),
534
+ h('p', { style: { fontSize: 11, color: 'var(--text-muted)', marginTop: -8, marginBottom: 16 } },
535
+ 'Heartbeats only run between these hours. Set to 12:00 AM \u2013 12:00 AM for 24/7.'
536
+ ),
537
+
538
+ // Tokens per beat
539
+ h('div', { style: fieldGroupStyle },
540
+ h('label', { style: labelStyle }, 'Estimated Tokens per Beat'),
541
+ h('select', { style: Object.assign({}, inputStyle, { cursor: 'pointer' }), value: form.tokensPerBeat, onChange: function(e) { setForm(function(f) { return Object.assign({}, f, { tokensPerBeat: e.target.value }); }); } },
542
+ h('option', { value: 1000 }, '~1K (minimal — just HEARTBEAT_OK)'),
543
+ h('option', { value: 3000 }, '~3K (typical — light checks)'),
544
+ h('option', { value: 5000 }, '~5K (moderate — email/calendar checks)'),
545
+ h('option', { value: 10000 }, '~10K (heavy — full inbox scan + actions)')
546
+ )
547
+ ),
548
+
549
+ // Custom prompt
550
+ h('div', { style: fieldGroupStyle },
551
+ h('label', { style: labelStyle }, 'Custom Heartbeat Prompt (optional)'),
552
+ h('textarea', {
553
+ style: Object.assign({}, inputStyle, { minHeight: 60, resize: 'vertical', fontFamily: 'var(--font-mono)', fontSize: 12 }),
554
+ value: form.prompt,
555
+ onChange: function(e) { setForm(function(f) { return Object.assign({}, f, { prompt: e.target.value }); }); },
556
+ placeholder: 'Leave empty for default. E.g.: Check emails, calendar events in next 2h, and Slack mentions.'
557
+ }),
558
+ h('p', { style: { fontSize: 11, color: 'var(--text-muted)', marginTop: 4 } }, 'This prompt is sent to the agent each heartbeat cycle. The agent can reply HEARTBEAT_OK if nothing needs attention.')
559
+ )
560
+ )
561
+ )
562
+ : h(Fragment, null,
563
+ hb.enabled === false
564
+ ? h('div', { style: { display: 'flex', alignItems: 'center', gap: 8 } },
565
+ h('span', { className: 'badge badge-neutral', style: { fontSize: 11 } }, 'Disabled'),
566
+ h('span', { style: { fontSize: 13, color: 'var(--text-muted)' } }, 'Heartbeat is turned off. Agent won\'t check in proactively.')
567
+ )
568
+ : h(Fragment, null,
569
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 } },
570
+ h('span', { className: 'badge badge-success', style: { fontSize: 11 } }, 'Enabled'),
571
+ h('span', { style: { fontSize: 12, color: 'var(--text-muted)' } },
572
+ 'Every ' + (hb.intervalMinutes || 30) + ' min' +
573
+ (hb.activeHoursStart != null ? ' \u00B7 ' + (hb.activeHoursStart === 0 ? '12' : hb.activeHoursStart > 12 ? (hb.activeHoursStart - 12) : hb.activeHoursStart) + ':00' + (hb.activeHoursStart < 12 ? 'AM' : 'PM') + '\u2013' + (hb.activeHoursEnd === 0 ? '12' : hb.activeHoursEnd > 12 ? (hb.activeHoursEnd - 12) : hb.activeHoursEnd) + ':00' + (hb.activeHoursEnd < 12 ? 'AM' : 'PM') : ' \u00B7 24/7')
574
+ )
575
+ ),
576
+ hb.prompt && h('div', { style: { padding: '8px 12px', background: 'var(--bg-secondary)', borderRadius: 'var(--radius)', fontSize: 12, fontFamily: 'var(--font-mono)', color: 'var(--text-secondary)', whiteSpace: 'pre-wrap', maxHeight: 80, overflow: 'auto' } }, hb.prompt)
577
+ )
578
+ )
579
+ );
580
+ }
581
+
208
582
  // ─── Main Component ───
209
583
 
210
584
  export function ConfigurationSection(props) {
@@ -422,7 +796,13 @@ export function ConfigurationSection(props) {
422
796
  )
423
797
  ),
424
798
 
425
- // ═══ Card 3: Meeting Voice ═══
799
+ // ═══ Card 3: Model Fallback / Backup Providers ═══
800
+ h(ModelFallbackCard, { config: config, saving: saving, providers: providers, saveUpdates: saveUpdates }),
801
+
802
+ // ═══ Card 4: Heartbeat Configuration ═══
803
+ h(HeartbeatCard, { config: config, saving: saving, modelObj: modelObj, modelStr: modelStr, saveUpdates: saveUpdates }),
804
+
805
+ // ═══ Card 5: Meeting Voice ═══
426
806
  h('div', { className: 'card', style: { padding: 20, marginBottom: 20 } },
427
807
  h(CardHeader, {
428
808
  title: 'Meeting Voice (ElevenLabs)',
@@ -465,7 +845,7 @@ export function ConfigurationSection(props) {
465
845
  : h('span', { style: { fontSize: 13, color: 'var(--text-muted)' } }, 'No voice configured \u2014 agent uses text only in meetings')
466
846
  ),
467
847
 
468
- // ═══ Card 4: Description ═══
848
+ // ═══ Card 6: Description ═══
469
849
  h('div', { className: 'card', style: { padding: 20, marginBottom: 20 } },
470
850
  h(CardHeader, {
471
851
  title: 'Description',
@@ -493,7 +873,7 @@ export function ConfigurationSection(props) {
493
873
  : h('div', { style: { fontSize: 14, color: displayDescription ? 'var(--text-primary)' : 'var(--text-muted)', lineHeight: 1.6 } }, displayDescription || 'No description set.')
494
874
  ),
495
875
 
496
- // ═══ Card 5: Soul Template (only if set) ═══
876
+ // ═══ Card 7: Soul Template (only if set) ═══
497
877
  config.soulId && h('div', { className: 'card', style: { padding: 20 } },
498
878
  h('h4', { style: { margin: '0 0 16px', fontSize: 14, fontWeight: 600 } }, 'Role Template'),
499
879
  h('span', { className: 'badge badge-primary' }, config.soulId.replace(/-/g, ' ').replace(/\b\w/g, function(c) { return c.toUpperCase(); }))