@agent-native/core 0.172.3 → 0.172.6

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 (55) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/calendar/actions/get-settings.ts +2 -5
  3. package/corpus/templates/calendar/actions/list-events.ts +21 -80
  4. package/corpus/templates/calendar/actions/update-settings.ts +11 -16
  5. package/corpus/templates/calendar/actions/view-screen.ts +25 -10
  6. package/corpus/templates/calendar/app/components/calendar/CommandPalette.tsx +7 -1
  7. package/corpus/templates/calendar/app/components/calendar/EventCard.tsx +5 -2
  8. package/corpus/templates/calendar/app/lib/calendar-timezone.ts +3 -18
  9. package/corpus/templates/calendar/app/lib/event-form-utils.ts +3 -62
  10. package/corpus/templates/calendar/changelog/2026-07-22-calendar-grid-local-time.md +6 -0
  11. package/corpus/templates/calendar/changelog/2026-08-19-the-calendar-loads-again-for-accounts-whose-saved-timezone-w.md +6 -0
  12. package/corpus/templates/calendar/server/handlers/settings.ts +8 -24
  13. package/corpus/templates/calendar/server/lib/calendar-settings.ts +58 -0
  14. package/corpus/templates/calendar/server/lib/find-time.ts +15 -93
  15. package/corpus/templates/calendar/shared/settings.ts +15 -9
  16. package/corpus/templates/calendar/shared/timezone.ts +127 -0
  17. package/corpus/templates/factory/AGENTS.md +5 -3
  18. package/corpus/templates/factory/actions/babysit-pull-request.ts +6 -0
  19. package/corpus/templates/factory/actions/list-factory-automations.ts +2 -2
  20. package/corpus/templates/factory/actions/save-factory-automation.ts +0 -5
  21. package/corpus/templates/factory/changelog/2026-08-24-slack-checking-works-for-teammate-created-factories.md +6 -0
  22. package/corpus/templates/factory/changelog/2026-08-24-teammates-can-edit-and-run-factory-jobs.md +6 -0
  23. package/corpus/templates/factory/server/lib/factory-scope.ts +8 -0
  24. package/corpus/templates/factory/server/lib/require-factory-automation.ts +1 -9
  25. package/corpus/templates/factory/server/plugins/factory-scheduler-job.ts +2 -1
  26. package/corpus/templates/slides/actions/list-decks.ts +79 -5
  27. package/corpus/templates/slides/app/components/deck/DeckCard.tsx +21 -1
  28. package/corpus/templates/slides/app/components/editor/ShareDialog.tsx +49 -21
  29. package/corpus/templates/slides/app/context/DeckContext.tsx +49 -21
  30. package/corpus/templates/slides/app/pages/DeckEditor.tsx +2 -2
  31. package/corpus/templates/slides/app/pages/Index.tsx +11 -13
  32. package/corpus/templates/slides/changelog/2026-08-24-deck-previews-and-sharing-live-in-the-overflow-menu.md +5 -0
  33. package/dist/agent/production-agent.js +5 -1
  34. package/dist/agent/types.d.ts +5 -0
  35. package/dist/automations/service.d.ts +6 -0
  36. package/dist/automations/service.js +20 -0
  37. package/dist/client/settings/SettingsPanel.js +80 -33
  38. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  39. package/dist/jobs/run-now.js +2 -2
  40. package/dist/observability/posthog-ai.d.ts +36 -11
  41. package/dist/observability/posthog-ai.js +99 -30
  42. package/dist/observability/routes.js +9 -2
  43. package/dist/observability/traces.js +330 -142
  44. package/dist/tracking/providers.js +9 -3
  45. package/dist/vite/client.js +5 -2
  46. package/docs/content/docs-components.mdx +20 -1
  47. package/docs/content/observability.mdx +27 -10
  48. package/docs/content/template-calendar-agent.mdx +63 -26
  49. package/docs/content/template-calendar-developers.mdx +165 -40
  50. package/docs/content/template-calendar-features.mdx +276 -0
  51. package/docs/content/template-calendar-integrations.mdx +78 -0
  52. package/docs/content/template-calendar.mdx +56 -123
  53. package/package.json +1 -1
  54. package/docs/content/template-calendar-booking-links.mdx +0 -73
  55. package/docs/content/template-calendar-scheduling.mdx +0 -128
@@ -12,6 +12,7 @@ import { AgentWorkspaceContent } from "../agent-page/AgentWorkspaceContent.js";
12
12
  import { AGENT_PROVIDER_CATALOG, getAgentProviderOption, providerIdForEngine, } from "../agent-provider-catalog.js";
13
13
  import { agentNativePath } from "../api-path.js";
14
14
  import { BuilderBMark } from "../builder-mark.js";
15
+ import { fetchAgentEngineStatus, fetchEnvironmentStatus, } from "../client-status-requests.js";
15
16
  import { Popover, PopoverContent, PopoverTrigger, } from "../components/ui/popover.js";
16
17
  import { Tooltip, TooltipContent, TooltipTrigger, } from "../components/ui/tooltip.js";
17
18
  import { useT } from "../i18n.js";
@@ -332,7 +333,7 @@ const PROVIDER_DOCS = {
332
333
  "ai-sdk:mistral": "https://console.mistral.ai/api-keys/",
333
334
  "ai-sdk:cohere": "https://dashboard.cohere.com/api-keys",
334
335
  };
335
- function LLMSectionInner({ builderFlow, builderLoading, connectUrl, connected, orgName, envManaged, credentialSource, grouped = false, open, onToggle, }) {
336
+ function LLMSectionInner({ builderFlow, builderLoading, builderStatusAvailable, connectUrl, connected, orgName, envManaged, credentialSource, grouped = false, open, onToggle, }) {
336
337
  const isPage = useSettingsSurface() === "page";
337
338
  const t = useT();
338
339
  const [envKeys, setEnvKeys] = useState([]);
@@ -354,24 +355,40 @@ function LLMSectionInner({ builderFlow, builderLoading, connectUrl, connected, o
354
355
  const [testResult, setTestResult] = useState(null);
355
356
  const [settingsStatus, setSettingsStatus] = useState(null);
356
357
  const [disconnectError, setDisconnectError] = useState(null);
357
- const [envLoaded, setEnvLoaded] = useState(false);
358
+ const [envProbeAvailable, setEnvProbeAvailable] = useState(false);
358
359
  const [enginesLoaded, setEnginesLoaded] = useState(false);
359
- const [statusLoaded, setStatusLoaded] = useState(false);
360
- const initialLoading = !envLoaded || !enginesLoaded || !statusLoaded || !!builderLoading;
361
- useEffect(() => {
362
- fetch(agentNativePath("/_agent-native/env-status"))
363
- .then((r) => (r.ok ? r.json() : []))
364
- .then(setEnvKeys)
365
- .catch(() => { })
366
- .finally(() => setEnvLoaded(true));
367
- }, [saved]);
360
+ const [statusProbeAvailable, setStatusProbeAvailable] = useState(false);
361
+ const probeGenerationRef = useRef({ env: 0, status: 0 });
362
+ const initialLoading = !enginesLoaded || !!builderLoading;
363
+ const refreshEnvKeys = useCallback(() => {
364
+ const generation = ++probeGenerationRef.current.env;
365
+ setEnvProbeAvailable(false);
366
+ setEnvKeys([]);
367
+ void fetchEnvironmentStatus().then((result) => {
368
+ if (generation !== probeGenerationRef.current.env)
369
+ return;
370
+ if (result.state !== "available" || !Array.isArray(result.value)) {
371
+ return;
372
+ }
373
+ setEnvKeys(result.value);
374
+ setEnvProbeAvailable(true);
375
+ });
376
+ }, []);
368
377
  const notifyConfigChanged = useCallback(() => {
369
378
  window.dispatchEvent(new CustomEvent("agent-engine:configured-changed"));
370
379
  }, []);
371
380
  const refreshSettingsStatus = useCallback(() => {
372
- fetch(agentNativePath("/_agent-native/agent-engine/status"))
373
- .then((r) => (r.ok ? r.json() : null))
374
- .then((data) => {
381
+ const generation = ++probeGenerationRef.current.status;
382
+ setStatusProbeAvailable(false);
383
+ setSettingsStatus(null);
384
+ setBaseUrlConfigured(false);
385
+ void fetchAgentEngineStatus()
386
+ .then((result) => {
387
+ if (generation !== probeGenerationRef.current.status)
388
+ return;
389
+ if (result.state !== "available")
390
+ return;
391
+ const data = result.value;
375
392
  setBaseUrlConfigured(Boolean(data?.openAiBaseUrlConfigured));
376
393
  if (data?.configured &&
377
394
  typeof data.engine === "string" &&
@@ -387,13 +404,34 @@ function LLMSectionInner({ builderFlow, builderLoading, connectUrl, connected, o
387
404
  else {
388
405
  setSettingsStatus(null);
389
406
  }
407
+ setStatusProbeAvailable(true);
390
408
  })
391
- .catch(() => { })
392
- .finally(() => setStatusLoaded(true));
409
+ .catch(() => { });
393
410
  }, []);
411
+ useEffect(() => {
412
+ refreshEnvKeys();
413
+ }, [refreshEnvKeys]);
394
414
  useEffect(() => {
395
415
  refreshSettingsStatus();
396
416
  }, [refreshSettingsStatus]);
417
+ useEffect(() => {
418
+ const refresh = () => {
419
+ refreshEnvKeys();
420
+ refreshSettingsStatus();
421
+ };
422
+ window.addEventListener("agent-engine:configured-changed", refresh);
423
+ window.addEventListener("focus", refresh);
424
+ const onVisibilityChange = () => {
425
+ if (document.visibilityState === "visible")
426
+ refresh();
427
+ };
428
+ document.addEventListener("visibilitychange", onVisibilityChange);
429
+ return () => {
430
+ window.removeEventListener("agent-engine:configured-changed", refresh);
431
+ window.removeEventListener("focus", refresh);
432
+ document.removeEventListener("visibilitychange", onVisibilityChange);
433
+ };
434
+ }, [refreshEnvKeys, refreshSettingsStatus]);
397
435
  useEffect(() => {
398
436
  callAction("manage-agent-engine", { action: "list" })
399
437
  .then((data) => {
@@ -441,14 +479,21 @@ function LLMSectionInner({ builderFlow, builderLoading, connectUrl, connected, o
441
479
  }
442
480
  return configured;
443
481
  }, [envKeys, settingsStatus]);
444
- const builderConnected = connected || builderFlow.configured;
445
- const anyKeyConfigured = builderConnected ||
446
- (selectedEnginePackageInstalled &&
447
- (envConfigured || settingsConfigured || configuredProviderIds.size > 0));
482
+ const builderConnected = builderStatusAvailable && (connected || builderFlow.configured);
483
+ const configurationKnown = envProbeAvailable && statusProbeAvailable;
484
+ const builderEngineSelected = selectedEngine === "builder";
485
+ const selectedConfigurationKnown = builderEngineSelected
486
+ ? builderStatusAvailable
487
+ : configurationKnown;
488
+ const anyKeyConfigured = (builderEngineSelected && builderConnected) ||
489
+ (!builderEngineSelected &&
490
+ configurationKnown &&
491
+ selectedEnginePackageInstalled &&
492
+ (envConfigured || settingsConfigured));
448
493
  const sourceBadge = computeSourceBadge({
449
- settingsConfigured,
450
- settingsStatus,
451
- envConfigured,
494
+ settingsConfigured: configurationKnown && settingsConfigured,
495
+ settingsStatus: configurationKnown ? settingsStatus : null,
496
+ envConfigured: configurationKnown && envConfigured,
452
497
  envVar,
453
498
  builderConnected,
454
499
  });
@@ -479,7 +524,6 @@ function LLMSectionInner({ builderFlow, builderLoading, connectUrl, connected, o
479
524
  setBaseUrlConfigured(true);
480
525
  if (clearBaseUrl)
481
526
  setBaseUrlConfigured(false);
482
- refreshSettingsStatus();
483
527
  notifyConfigChanged();
484
528
  setTimeout(() => setSaved(false), 2000);
485
529
  }
@@ -496,7 +540,6 @@ function LLMSectionInner({ builderFlow, builderLoading, connectUrl, connected, o
496
540
  if (res.ok) {
497
541
  setTestResult(null);
498
542
  setApplyNote(false);
499
- refreshSettingsStatus();
500
543
  notifyConfigChanged();
501
544
  return;
502
545
  }
@@ -560,7 +603,6 @@ function LLMSectionInner({ builderFlow, builderLoading, connectUrl, connected, o
560
603
  setCurrentEngine(selectedEngine);
561
604
  setCurrentModel(selectedModel);
562
605
  setApplyNote(true);
563
- refreshSettingsStatus();
564
606
  notifyConfigChanged();
565
607
  setTimeout(() => setApplyNote(false), 4000);
566
608
  }
@@ -568,7 +610,9 @@ function LLMSectionInner({ builderFlow, builderLoading, connectUrl, connected, o
568
610
  setApplyError(err instanceof Error ? err.message : String(err));
569
611
  }
570
612
  };
571
- return (_jsx(SettingsSection, { id: settingsSectionDomId("llm"), icon: _jsx(IconBrain, { size: 14 }), title: "LLM", required: true, connected: initialLoading ? undefined : anyKeyConfigured, subtitle: isPage
613
+ return (_jsx(SettingsSection, { id: settingsSectionDomId("llm"), icon: _jsx(IconBrain, { size: 14 }), title: "LLM", required: true, connected: initialLoading || !selectedConfigurationKnown
614
+ ? undefined
615
+ : anyKeyConfigured, subtitle: isPage
572
616
  ? undefined
573
617
  : t("agentPanel.builderOrOwnKeys", {
574
618
  defaultValue: "Choose Builder.io or custom keys.",
@@ -576,11 +620,11 @@ function LLMSectionInner({ builderFlow, builderLoading, connectUrl, connected, o
576
620
  ? "flex items-center justify-between gap-4 px-5 py-4 sm:px-6"
577
621
  : "flex items-center justify-between gap-2"), children: [isPage && (_jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "text-sm font-medium text-foreground", children: "AI provider" }), _jsx("p", { className: "mt-1 text-sm leading-6 text-muted-foreground", children: t("agentPanel.builderOrOwnKeys", {
578
622
  defaultValue: "Choose Builder.io or custom keys.",
579
- }) })] })), _jsxs("div", { className: cn("flex flex-wrap items-center justify-end gap-2"), children: [!anyKeyConfigured && (_jsx(UseBuilderCard, { builderFlow: builderFlow, connectUrl: connectUrl, connected: connected, orgName: orgName, envManaged: envManaged, credentialSource: credentialSource, trackingSource: "llm_settings", trackingFlow: "connect_llm", label: "Connect Builder.io", compact: true })), _jsx(ManualSetupCard, { id: "llm-manual-setup", title: "Custom keys", hint: manualSetupHint, sourceBadge: builderConnected ? undefined : sourceBadge, bare: isPage, popover: true, popoverLabel: settingsConfigured
623
+ }) })] })), _jsxs("div", { className: cn("flex flex-wrap items-center justify-end gap-2"), children: [selectedConfigurationKnown && !anyKeyConfigured && (_jsx(UseBuilderCard, { builderFlow: builderFlow, connectUrl: connectUrl, connected: builderConnected, orgName: orgName, envManaged: envManaged, credentialSource: credentialSource, trackingSource: "llm_settings", trackingFlow: "connect_llm", label: "Connect Builder.io", compact: true })), _jsx(ManualSetupCard, { id: "llm-manual-setup", title: "Custom keys", hint: manualSetupHint, sourceBadge: builderConnected ? undefined : sourceBadge, bare: isPage, popover: true, popoverLabel: settingsConfigured
580
624
  ? "Manage"
581
625
  : t("agentPanel.addOwnKeys", {
582
626
  defaultValue: "Custom keys",
583
- }), summaryContent: anyKeyConfigured ? (_jsx(UseBuilderCard, { builderFlow: builderFlow, connectUrl: connectUrl, connected: connected, orgName: orgName, envManaged: envManaged, credentialSource: credentialSource, trackingSource: "llm_settings", trackingFlow: "connect_llm", label: "Connect Builder.io" })) : undefined, children: _jsxs("div", { className: "space-y-2 mb-1", children: [_jsx(AgentProviderPicker, { value: selectedProvider, configuredProviders: configuredProviderIds, layout: isPage ? "page" : "compact", onChange: (provider) => {
627
+ }), summaryContent: selectedConfigurationKnown && anyKeyConfigured ? (_jsx(UseBuilderCard, { builderFlow: builderFlow, connectUrl: connectUrl, connected: builderConnected, orgName: orgName, envManaged: envManaged, credentialSource: credentialSource, trackingSource: "llm_settings", trackingFlow: "connect_llm", label: "Connect Builder.io" })) : undefined, children: _jsxs("div", { className: "space-y-2 mb-1", children: [_jsx(AgentProviderPicker, { value: selectedProvider, configuredProviders: configurationKnown ? configuredProviderIds : undefined, layout: isPage ? "page" : "compact", onChange: (provider) => {
584
628
  const option = getAgentProviderOption(provider);
585
629
  setSelectedEngine(option.engine);
586
630
  setSelectedModel(option.defaultModel);
@@ -611,7 +655,9 @@ function LLMSectionInner({ builderFlow, builderLoading, connectUrl, connected, o
611
655
  : `${envVar} configured`] })) : envVar ? (_jsxs("div", { className: "flex gap-1.5", children: [_jsx("input", { type: "password", value: apiKey, onChange: (e) => setApiKey(e.target.value), onKeyDown: (e) => {
612
656
  if (e.key === "Enter")
613
657
  handleSave();
614
- }, placeholder: PROVIDER_ENV_PLACEHOLDERS[envVar] ?? "...", className: cn(textInputClass(isPage), "flex-1"), style: isPage ? CONTROL_STYLE_PAGE : undefined }), _jsx(Button, { intent: "primary", emphasis: "solid", onClick: handleSave, disabled: !providerSettingsChanged || saving, className: pillButtonClass(isPage, "solid"), children: saving ? (_jsx(IconLoader2, { size: isPage ? 14 : 10, className: "animate-spin" })) : saved ? (_jsx(IconCheck, { size: isPage ? 14 : 10 })) : ("Save") })] })) : null, _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Button, { intent: "neutral", emphasis: "outline", onClick: handleTest, disabled: testing, className: pillButtonClass(isPage, "outline"), children: testing ? (_jsxs("span", { className: "flex items-center gap-1", children: [_jsx(IconLoader2, { size: isPage ? 14 : 10, className: "animate-spin" }), "Testing\u2026"] })) : ("Test") }), PROVIDER_DOCS[selectedEngine] ? (_jsxs("a", { href: PROVIDER_DOCS[selectedEngine], target: "_blank", rel: "noopener noreferrer", className: cn(pillButtonClass(isPage, "outline"), "no-underline"), children: ["Get an API key", _jsx(IconExternalLink, { size: isPage ? 14 : 10 })] })) : null, engineChanged && (_jsx(Button, { intent: "primary", emphasis: "solid", onClick: handleApply, className: pillButtonClass(isPage, "solid"), children: "Apply" })), settingsStatus != null && (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsx(Button, { intent: "danger", emphasis: "outline", onClick: handleDisconnect, className: cn(pillButtonClass(isPage, "outline"), "ms-auto text-muted-foreground hover:bg-destructive/10 hover:text-destructive hover:border-destructive/40"), children: "Disconnect" }) }), _jsx(TooltipContent, { children: "Clear the saved engine \u2014 the app will fall back to the default until you re-apply." })] }))] }), testResult && testResult.ok && (_jsxs("p", { className: cn("flex items-center gap-1 text-primary", isPage ? "text-xs" : "text-[10px]"), children: [_jsx(IconCheck, { size: isPage ? 14 : 10 }), "Test passed \u2014 ", testResult.latencyMs, "ms"] })), testResult && testResult.ok === false && (_jsxs("p", { className: cn("text-destructive", isPage ? "text-xs" : "text-[10px]"), children: ["Test failed: ", testResult.error] })), disconnectError && (_jsxs("p", { className: cn("text-destructive", isPage ? "text-xs" : "text-[10px]"), children: ["Disconnect failed: ", disconnectError] })), applyError && (_jsxs("p", { className: cn("text-destructive", isPage ? "text-xs" : "text-[10px]"), children: ["Apply failed: ", applyError] })), applyNote && (_jsx("p", { className: cn("text-muted-foreground", isPage ? "text-xs" : "text-[10px]"), children: "Changes take effect on next conversation" }))] }) })] })] })) }));
658
+ }, placeholder: PROVIDER_ENV_PLACEHOLDERS[envVar] ?? "...", className: cn(textInputClass(isPage), "flex-1"), style: isPage ? CONTROL_STYLE_PAGE : undefined }), _jsx(Button, { intent: "primary", emphasis: "solid", onClick: handleSave, disabled: !providerSettingsChanged || saving, className: pillButtonClass(isPage, "solid"), children: saving ? (_jsx(IconLoader2, { size: isPage ? 14 : 10, className: "animate-spin" })) : saved ? (_jsx(IconCheck, { size: isPage ? 14 : 10 })) : ("Save") })] })) : null, _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Button, { intent: "neutral", emphasis: "outline", onClick: handleTest, disabled: testing ||
659
+ !selectedConfigurationKnown ||
660
+ !anyKeyConfigured, className: pillButtonClass(isPage, "outline"), children: testing ? (_jsxs("span", { className: "flex items-center gap-1", children: [_jsx(IconLoader2, { size: isPage ? 14 : 10, className: "animate-spin" }), "Testing\u2026"] })) : ("Test") }), PROVIDER_DOCS[selectedEngine] ? (_jsxs("a", { href: PROVIDER_DOCS[selectedEngine], target: "_blank", rel: "noopener noreferrer", className: cn(pillButtonClass(isPage, "outline"), "no-underline"), children: ["Get an API key", _jsx(IconExternalLink, { size: isPage ? 14 : 10 })] })) : null, engineChanged && (_jsx(Button, { intent: "primary", emphasis: "solid", onClick: handleApply, className: pillButtonClass(isPage, "solid"), children: "Apply" })), settingsStatus != null && (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsx(Button, { intent: "danger", emphasis: "outline", onClick: handleDisconnect, className: cn(pillButtonClass(isPage, "outline"), "ms-auto text-muted-foreground hover:bg-destructive/10 hover:text-destructive hover:border-destructive/40"), children: "Disconnect" }) }), _jsx(TooltipContent, { children: "Clear the saved engine \u2014 the app will fall back to the default until you re-apply." })] }))] }), testResult && testResult.ok && (_jsxs("p", { className: cn("flex items-center gap-1 text-primary", isPage ? "text-xs" : "text-[10px]"), children: [_jsx(IconCheck, { size: isPage ? 14 : 10 }), "Test passed \u2014 ", testResult.latencyMs, "ms"] })), testResult && testResult.ok === false && (_jsxs("p", { className: cn("text-destructive", isPage ? "text-xs" : "text-[10px]"), children: ["Test failed: ", testResult.error] })), disconnectError && (_jsxs("p", { className: cn("text-destructive", isPage ? "text-xs" : "text-[10px]"), children: ["Disconnect failed: ", disconnectError] })), applyError && (_jsxs("p", { className: cn("text-destructive", isPage ? "text-xs" : "text-[10px]"), children: ["Apply failed: ", applyError] })), applyNote && (_jsx("p", { className: cn("text-muted-foreground", isPage ? "text-xs" : "text-[10px]"), children: "Changes take effect on next conversation" }))] }) })] })] })) }));
615
661
  }
616
662
  function friendlyAppName(appId) {
617
663
  return appId
@@ -1056,10 +1102,11 @@ function CapabilityStatusStrip({ isDevMode, builderConnected, builderLoading, bu
1056
1102
  return (_jsxs("div", { className: "rounded-md border border-border bg-muted/20 px-2.5 py-2", children: [_jsx("div", { className: "mb-1.5 text-[10px] font-medium text-muted-foreground", children: "Available now" }), _jsxs("div", { className: "space-y-1.5", children: [_jsx(CapabilityStatusRow, { label: "App", value: "Chat + actions", active: true }), _jsx(CapabilityStatusRow, { label: "Code", value: codeLabel, active: codeAvailable }), _jsx(CapabilityStatusRow, { label: "Builder", active: builderConnected, value: builderLoading ? (_jsx(Skeleton, { className: "h-3 w-16" })) : builderConnected ? ("Connected") : (_jsx(Button, { type: "button", intent: "neutral", emphasis: "outline", onClick: onOpenLlm, className: "rounded border border-border px-1.5 py-0.5 text-[10px] text-muted-foreground transition-colors hover:bg-accent/40 hover:text-foreground", children: "Connect" })) })] })] }));
1057
1103
  }
1058
1104
  function SettingsPanelContent({ isDevMode, initialSection, sectionRequestKey, sections = ALL_SETTINGS_SECTIONS, showCapabilityStrip = true, className, surface = "sidebar", builderConnectionOwnedExternally = false, agentAdditionalContent, }) {
1059
- const { status: builder, loading: builderLoading } = useBuilderStatus({
1105
+ const { status: builder, loading: builderLoading, stale: builderStatusStale, } = useBuilderStatus({
1060
1106
  enabled: !builderConnectionOwnedExternally,
1061
1107
  });
1062
1108
  const connected = builder?.configured ?? false;
1109
+ const builderStatusAvailable = !builderLoading && !builderStatusStale && builder != null;
1063
1110
  const connectUrl = builder?.connectUrl;
1064
1111
  const orgName = builder?.orgName;
1065
1112
  const envManaged = !!builder?.envManaged;
@@ -1088,7 +1135,7 @@ function SettingsPanelContent({ isDevMode, initialSection, sectionRequestKey, se
1088
1135
  const isPage = surface === "page";
1089
1136
  const isWorkspacePage = isPage && sections.includes("hosting");
1090
1137
  return (_jsx(SettingsSurfaceProvider, { surface: surface, children: _jsxs("div", { className: cn(isPage ? "space-y-8" : "flex-1 min-h-0 overflow-y-auto p-3 space-y-2", className), style: isPage ? undefined : { overflowY: "auto" }, children: [showCapabilityStrip && (_jsx(CapabilityStatusStrip, { isDevMode: isDevMode, builderConnected: connected, builderLoading: builderLoading, builderBranchesAvailable: builderBranchesAvailable, onOpenLlm: () => openSettingsSection("llm", true) })), isPage &&
1091
- ["llm", "app-models", "limits", "voice"].some((section) => shouldShowSection(section)) && (_jsxs(SettingsGroup, { title: "Agent", children: [shouldShowSection("llm") && (_jsx(LLMSectionInner, { builderFlow: builderFlow, builderLoading: builderLoading, connectUrl: connectUrl, connected: connected, orgName: orgName, envManaged: envManaged, credentialSource: credentialSource, grouped: true, open: openSection === "llm", onToggle: () => toggle("llm") })), shouldShowSection("app-models") && (_jsx(AppModelDefaultsSectionInner, { grouped: true, open: openSection === "app-models", onToggle: () => toggle("app-models") })), shouldShowSection("limits") && (_jsx(AgentLimitsSectionInner, { grouped: true, open: openSection === "limits", onToggle: () => toggle("limits") })), shouldShowSection("voice") && (_jsx(SettingsSection, { id: settingsSectionDomId("voice"), icon: _jsx(IconMicrophone, { size: 14 }), title: "Voice Transcription", subtitle: "Choose how voice input is transcribed.", grouped: true, flat: true, open: openSection === "voice", onToggle: () => toggle("voice"), children: _jsx(VoiceTranscriptionSection, { compact: true }) }))] })), isPage && agentAdditionalContent ? (_jsx(SettingsGroup, { title: "Notifications", children: agentAdditionalContent })) : null, isPage &&
1138
+ ["llm", "app-models", "limits", "voice"].some((section) => shouldShowSection(section)) && (_jsxs(SettingsGroup, { title: "Agent", children: [shouldShowSection("llm") && (_jsx(LLMSectionInner, { builderFlow: builderFlow, builderLoading: builderLoading, builderStatusAvailable: builderStatusAvailable, connectUrl: connectUrl, connected: connected, orgName: orgName, envManaged: envManaged, credentialSource: credentialSource, grouped: true, open: openSection === "llm", onToggle: () => toggle("llm") })), shouldShowSection("app-models") && (_jsx(AppModelDefaultsSectionInner, { grouped: true, open: openSection === "app-models", onToggle: () => toggle("app-models") })), shouldShowSection("limits") && (_jsx(AgentLimitsSectionInner, { grouped: true, open: openSection === "limits", onToggle: () => toggle("limits") })), shouldShowSection("voice") && (_jsx(SettingsSection, { id: settingsSectionDomId("voice"), icon: _jsx(IconMicrophone, { size: 14 }), title: "Voice Transcription", subtitle: "Choose how voice input is transcribed.", grouped: true, flat: true, open: openSection === "voice", onToggle: () => toggle("voice"), children: _jsx(VoiceTranscriptionSection, { compact: true }) }))] })), isPage && agentAdditionalContent ? (_jsx(SettingsGroup, { title: "Notifications", children: agentAdditionalContent })) : null, isPage &&
1092
1139
  ["automations", "background"].some((section) => shouldShowSection(section)) && (_jsxs(SettingsGroup, { title: "Agent workflows", children: [shouldShowSection("automations") && (_jsx(SettingsSection, { id: settingsSectionDomId("automations"), icon: _jsx(IconBolt, { size: 14 }), title: "Automations", subtitle: "Scheduled and event-triggered agent tasks.", grouped: true, flat: true, open: openSection === "automations", onToggle: () => toggle("automations"), children: _jsx(SettingsRow, { label: "Automations", description: "Schedule agent tasks or run them from events.", control: _jsxs("a", { href: "/settings/agent/automations", className: "inline-flex items-center gap-1 rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground no-underline transition-colors hover:bg-accent/40", children: ["Open automations", _jsx(IconExternalLink, { size: 14 })] }) }) })), shouldShowSection("background") && (_jsx(SettingsSection, { id: settingsSectionDomId("background"), icon: _jsx(IconGitBranch, { size: 14 }), title: "Background Agent", subtitle: "Make code changes from production mode via Builder.", grouped: true, flat: true, connected: connected, open: openSection === "background", onToggle: () => toggle("background"), children: _jsx(SettingsRow, { label: "Background agent", description: "Make code changes from production mode via Builder.", control: _jsx(UseBuilderCard, { builderFlow: builderFlow, connectUrl: connectUrl, connected: connected, orgName: orgName, envManaged: envManaged, credentialSource: credentialSource, trackingSource: "background_agent_settings", trackingFlow: "background_agent", compact: true }) }) }))] })), isWorkspacePage && (_jsxs(SettingsGroup, { title: "Workspace", children: [shouldShowSection("demo-mode") && (_jsx(SettingsRow, { id: settingsSectionDomId("demo-mode"), label: "Demo mode", description: "Use sample data in this browser for presentations.", control: _jsx(DemoModeSection, { compact: true }) })), shouldShowSection("hosting") && (_jsx(SettingsRow, { id: settingsSectionDomId("hosting"), label: "Hosting", description: "Deploy the app to the cloud.", control: _jsxs("div", { className: "flex flex-wrap items-center justify-end gap-2", children: [!connected && (_jsx(UseBuilderCard, { builderFlow: builderFlow, connectUrl: connectUrl, connected: connected, orgName: orgName, envManaged: envManaged, credentialSource: credentialSource, trackingSource: "hosting_settings", trackingFlow: "hosting", compact: true })), _jsx(ManualSetupCard, { title: "Set up manually", hint: "Deploy manually to Netlify, Vercel, Cloudflare, or any Nitro-supported target.", docsUrl: docsUrl("deployment", {
1093
1140
  campaign: "onboarding",
1094
1141
  content: "deployment_settings",
@@ -1101,7 +1148,7 @@ function SettingsPanelContent({ isDevMode, initialSection, sectionRequestKey, se
1101
1148
  }), dim: connected, bare: true, popover: true, popoverLabel: "Manage", summaryContent: connected ? (_jsx(UseBuilderCard, { builderFlow: builderFlow, connectUrl: connectUrl, connected: connected, orgName: orgName, envManaged: envManaged, credentialSource: credentialSource, trackingSource: "file_upload_settings", trackingFlow: "file_upload" })) : undefined, children: _jsx(FileStorageSettingsForm, {}) })] }) })), shouldShowSection("auth") && (_jsx(SettingsRow, { id: settingsSectionDomId("auth"), label: "Authentication", description: "Set up sign-in and access control.", control: _jsxs("div", { className: "flex flex-wrap items-center justify-end gap-2", children: [!connected && (_jsx(UseBuilderCard, { builderFlow: builderFlow, connectUrl: connectUrl, connected: connected, orgName: orgName, envManaged: envManaged, credentialSource: credentialSource, trackingSource: "auth_settings", trackingFlow: "auth", compact: true })), _jsx(ManualSetupCard, { title: "Set up manually", hint: "Configure Better Auth and optional Google or GitHub providers.", docsUrl: docsUrl("authentication", {
1102
1149
  campaign: "onboarding",
1103
1150
  content: "authentication_settings",
1104
- }), dim: connected, bare: true, popover: true, popoverLabel: "Manage", summaryContent: connected ? (_jsx(UseBuilderCard, { builderFlow: builderFlow, connectUrl: connectUrl, connected: connected, orgName: orgName, envManaged: envManaged, credentialSource: credentialSource, trackingSource: "auth_settings", trackingFlow: "auth" })) : undefined })] }) }))] })), !isPage && shouldShowSection("llm") && (_jsx(LLMSectionInner, { builderFlow: builderFlow, builderLoading: builderLoading, connectUrl: connectUrl, connected: connected, orgName: orgName, envManaged: envManaged, credentialSource: credentialSource, open: openSection === "llm", onToggle: () => toggle("llm") })), !isPage && shouldShowSection("app-models") && (_jsx(AppModelDefaultsSectionInner, { open: openSection === "app-models", onToggle: () => toggle("app-models") })), !isPage && shouldShowSection("limits") && (_jsx(AgentLimitsSectionInner, { open: openSection === "limits", onToggle: () => toggle("limits") })), !isPage && shouldShowSection("voice") && (_jsx(SettingsSection, { id: settingsSectionDomId("voice"), icon: _jsx(IconMicrophone, { size: 14 }), title: "Voice Transcription", subtitle: "How the composer microphone turns your voice into text.", flat: true, open: openSection === "voice", onToggle: () => toggle("voice"), children: _jsx(VoiceTranscriptionSection, {}) })), !isPage && shouldShowSection("demo-mode") && (_jsx(SettingsSection, { id: settingsSectionDomId("demo-mode"), icon: _jsx(IconEyeOff, { size: 14 }), title: "Demo mode", subtitle: "Replace displayed emails with realistic fake data in this browser and reshape supported charts for presentations. Backend, agent integrations, and agent results stay real and access-scoped.", flat: true, open: openSection === "demo-mode", onToggle: () => toggle("demo-mode"), children: _jsx(DemoModeSection, {}) })), !isPage && shouldShowSection("automations") && (_jsx(SettingsSection, { id: settingsSectionDomId("automations"), icon: _jsx(IconBolt, { size: 14 }), title: "Automations", subtitle: "Scheduled and event-triggered agent tasks.", flat: true, open: openSection === "automations", onToggle: () => toggle("automations"), children: _jsx(AutomationsSection, {}) })), shouldShowSection("secrets") && (_jsx(SettingsSection, { id: settingsSectionDomId("secrets"), icon: _jsx(IconKey, { size: 14 }), title: "API keys", subtitle: "Service credentials and automation keys.", flat: true, open: openSection === "secrets", onToggle: () => toggle("secrets"), children: _jsx(SecretsSection, { focusKey: focusSecretKey }) })), !isPage && shouldShowSection("hosting") && (_jsx(SettingsSection, { id: settingsSectionDomId("hosting"), icon: _jsx(IconCloud, { size: 14 }), title: "Hosting", subtitle: "Deploy your app to the cloud.", flat: true, connected: connected, open: openSection === "hosting", onToggle: () => toggle("hosting"), children: _jsxs("div", { className: "space-y-2", children: [_jsx(UseBuilderCard, { builderFlow: builderFlow, connectUrl: connectUrl, connected: connected, orgName: orgName, envManaged: envManaged, credentialSource: credentialSource, trackingSource: "hosting_settings", trackingFlow: "hosting" }), _jsx(ManualSetupCard, { hint: "Deploy manually to Netlify, Vercel, Cloudflare, or any Nitro-supported target.", docsUrl: docsUrl("deployment", {
1151
+ }), dim: connected, bare: true, popover: true, popoverLabel: "Manage", summaryContent: connected ? (_jsx(UseBuilderCard, { builderFlow: builderFlow, connectUrl: connectUrl, connected: connected, orgName: orgName, envManaged: envManaged, credentialSource: credentialSource, trackingSource: "auth_settings", trackingFlow: "auth" })) : undefined })] }) }))] })), !isPage && shouldShowSection("llm") && (_jsx(LLMSectionInner, { builderFlow: builderFlow, builderLoading: builderLoading, builderStatusAvailable: builderStatusAvailable, connectUrl: connectUrl, connected: connected, orgName: orgName, envManaged: envManaged, credentialSource: credentialSource, open: openSection === "llm", onToggle: () => toggle("llm") })), !isPage && shouldShowSection("app-models") && (_jsx(AppModelDefaultsSectionInner, { open: openSection === "app-models", onToggle: () => toggle("app-models") })), !isPage && shouldShowSection("limits") && (_jsx(AgentLimitsSectionInner, { open: openSection === "limits", onToggle: () => toggle("limits") })), !isPage && shouldShowSection("voice") && (_jsx(SettingsSection, { id: settingsSectionDomId("voice"), icon: _jsx(IconMicrophone, { size: 14 }), title: "Voice Transcription", subtitle: "How the composer microphone turns your voice into text.", flat: true, open: openSection === "voice", onToggle: () => toggle("voice"), children: _jsx(VoiceTranscriptionSection, {}) })), !isPage && shouldShowSection("demo-mode") && (_jsx(SettingsSection, { id: settingsSectionDomId("demo-mode"), icon: _jsx(IconEyeOff, { size: 14 }), title: "Demo mode", subtitle: "Replace displayed emails with realistic fake data in this browser and reshape supported charts for presentations. Backend, agent integrations, and agent results stay real and access-scoped.", flat: true, open: openSection === "demo-mode", onToggle: () => toggle("demo-mode"), children: _jsx(DemoModeSection, {}) })), !isPage && shouldShowSection("automations") && (_jsx(SettingsSection, { id: settingsSectionDomId("automations"), icon: _jsx(IconBolt, { size: 14 }), title: "Automations", subtitle: "Scheduled and event-triggered agent tasks.", flat: true, open: openSection === "automations", onToggle: () => toggle("automations"), children: _jsx(AutomationsSection, {}) })), shouldShowSection("secrets") && (_jsx(SettingsSection, { id: settingsSectionDomId("secrets"), icon: _jsx(IconKey, { size: 14 }), title: "API keys", subtitle: "Service credentials and automation keys.", flat: true, open: openSection === "secrets", onToggle: () => toggle("secrets"), children: _jsx(SecretsSection, { focusKey: focusSecretKey }) })), !isPage && shouldShowSection("hosting") && (_jsx(SettingsSection, { id: settingsSectionDomId("hosting"), icon: _jsx(IconCloud, { size: 14 }), title: "Hosting", subtitle: "Deploy your app to the cloud.", flat: true, connected: connected, open: openSection === "hosting", onToggle: () => toggle("hosting"), children: _jsxs("div", { className: "space-y-2", children: [_jsx(UseBuilderCard, { builderFlow: builderFlow, connectUrl: connectUrl, connected: connected, orgName: orgName, envManaged: envManaged, credentialSource: credentialSource, trackingSource: "hosting_settings", trackingFlow: "hosting" }), _jsx(ManualSetupCard, { hint: "Deploy manually to Netlify, Vercel, Cloudflare, or any Nitro-supported target.", docsUrl: docsUrl("deployment", {
1105
1152
  campaign: "onboarding",
1106
1153
  content: "deployment_settings",
1107
1154
  }), dim: connected })] }) })), !isPage && shouldShowSection("database") && (_jsx(SettingsSection, { id: settingsSectionDomId("database"), icon: _jsx(IconDatabase, { size: 14 }), title: "Database", subtitle: "Connect a cloud database for persistent storage.", flat: true, connected: connected, open: openSection === "database", onToggle: () => toggle("database"), children: _jsxs("div", { className: "space-y-2", children: [_jsx(UseBuilderCard, { builderFlow: builderFlow, connectUrl: connectUrl, connected: connected, orgName: orgName, envManaged: envManaged, credentialSource: credentialSource, trackingSource: "database_settings", trackingFlow: "database" }), _jsx(ManualSetupCard, { hint: "Set DATABASE_URL in your .env to connect Neon, Supabase, Turso, any Postgres/SQLite database, or local PGlite with pglite:./data/pglite.", docsUrl: docsUrl("database", {
@@ -17,11 +17,11 @@ declare const _default: import("../../action.js").ActionDefinition<{
17
17
  id?: undefined;
18
18
  provider?: undefined;
19
19
  } | {
20
+ error?: undefined;
20
21
  configured?: undefined;
21
22
  connectPath?: undefined;
22
23
  url: string;
23
24
  id: string;
24
25
  provider: string;
25
- error?: undefined;
26
26
  }>;
27
27
  export default _default;
@@ -1,5 +1,5 @@
1
1
  import { AGENT_CHAT_BACKGROUND_RUN_FIELD, dispatchPathTargetsNetlifyBackgroundFunction, resolveAgentChatProcessRunDispatchPath, } from "../agent/durable-background.js";
2
- import { canUpdateAutomationResource, } from "../automations/service.js";
2
+ import { canQueueAutomationRunNow, } from "../automations/service.js";
3
3
  import { isLocalDatabase } from "../db/client.js";
4
4
  import { organizationResourceOwner, resourceGetByPath, } from "../resources/store.js";
5
5
  import { fireInternalDispatch } from "../server/self-dispatch.js";
@@ -77,7 +77,7 @@ export async function queueAutomationRunNow(input) {
77
77
  statusCode: 404,
78
78
  });
79
79
  }
80
- if (!(await canUpdateAutomationResource(input, resource))) {
80
+ if (!(await canQueueAutomationRunNow(input, resource, input.scope))) {
81
81
  throw Object.assign(new Error("Only the automation's creator or an organization admin can run it."), { statusCode: 403 });
82
82
  }
83
83
  const { body } = parseJobResource(resource.content);
@@ -24,10 +24,10 @@
24
24
  * schema, and a name it does not define today it may define tomorrow with
25
25
  * different meaning — `input_truncated` / `spans_dropped` cannot collide.
26
26
  *
27
- * Content (`$ai_input` / `$ai_output_choices` / `$ai_input_state` /
28
- * `$ai_output_state`) is gated on config and always OMITTED when disabled.
29
- * Sending `[]` instead would be indistinguishable from a run that genuinely had
30
- * no messages.
27
+ * Content (a generation's `$ai_input` / `$ai_output_choices`, a span's
28
+ * `$ai_input_state` / `$ai_output_state`) is gated on config and always OMITTED
29
+ * when disabled. Sending `[]` instead would be indistinguishable from a run
30
+ * that genuinely had no messages.
31
31
  *
32
32
  * @see https://posthog.com/docs/ai-observability/traces
33
33
  * @see https://posthog.com/docs/ai-observability/spans
@@ -42,12 +42,18 @@ export interface AiErrorDetail {
42
42
  terminal_state?: string;
43
43
  retryable?: boolean;
44
44
  }
45
+ export declare function resolveAiError(isError: boolean, error: AiErrorDetail | undefined): AiErrorDetail | undefined;
45
46
  /**
46
47
  * Serialize a content value under a byte ceiling.
47
48
  *
48
- * Returns `{ truncated: true }` with a placeholder rather than a silently
49
- * shortened payload — a trace that shows half a conversation as if it were the
50
- * whole one is worse than one that says it was cut.
49
+ * A message list keeps the last user message behind a marker entry naming what
50
+ * was dropped — what was asked is what a trace is opened for, and it stays
51
+ * small. Replacing the whole conversation with a placeholder, which is what
52
+ * this did, left nothing at all.
53
+ *
54
+ * Anything else (a string, an object) still becomes a placeholder: there is no
55
+ * message to keep. Either way `truncated` is true, so a cut payload can never
56
+ * be read as a complete one.
51
57
  */
52
58
  export declare function boundAiContent(value: unknown): {
53
59
  value: unknown;
@@ -66,6 +72,10 @@ export interface AiTraceEventInput {
66
72
  durationMs: number;
67
73
  isError: boolean;
68
74
  error?: AiErrorDetail;
75
+ /** What KIND of failure this was — a terminal code, a cut-off reason, an
76
+ * engine error code. A classification, never content, so it ships even when
77
+ * the message is withheld. */
78
+ errorType?: string;
69
79
  inputTokens?: number;
70
80
  outputTokens?: number;
71
81
  costUsd?: number;
@@ -73,11 +83,15 @@ export interface AiTraceEventInput {
73
83
  /** Browser session id, when the run originated from a page. Links the trace
74
84
  * to PostHog session replay. */
75
85
  browserSessionId?: string;
76
- /** Omitted unless `capturePrompts` is on. */
77
- inputState?: unknown;
78
- outputState?: unknown;
79
86
  extraProperties?: Record<string, unknown>;
80
87
  }
88
+ /**
89
+ * The run's prompt and answer are NOT carried here. Every round-trip emits a
90
+ * generation with its own `$ai_input` / `$ai_output_choices`, so a trace-level
91
+ * copy repeated the first call's prompt and the last call's answer on a second
92
+ * event. The trace owns what only it knows: the run's name, failure, metadata
93
+ * and totals.
94
+ */
81
95
  export declare function emitAiTraceEvent(input: AiTraceEventInput): void;
82
96
  export interface AiSpanEventInput {
83
97
  runId: string;
@@ -90,6 +104,9 @@ export interface AiSpanEventInput {
90
104
  latencySeconds: number;
91
105
  isError: boolean;
92
106
  error?: AiErrorDetail;
107
+ /** The tool failure's class — `tool_error`, `interrupted`. A classification,
108
+ * never the tool's output, so it ships with `captureToolResults` off. */
109
+ errorType?: string;
93
110
  createdAt: number;
94
111
  browserSessionId?: string;
95
112
  /** Omitted unless `captureToolArgs` / `captureToolResults` are on. */
@@ -103,8 +120,12 @@ export interface AiFeedbackSurveyInput {
103
120
  threadId: string | null;
104
121
  userId: string | null;
105
122
  feedbackType: "thumbs_up" | "thumbs_down" | "category" | "text";
106
- /** The submitted value: sentiment label, chosen category, or free text. */
123
+ /** The submitted value: chosen category or free text. Ignored for thumbs,
124
+ * which answer a rating question and travel as PostHog's choice index. */
107
125
  value: string;
126
+ /** Groups the events of one response. A thumbs-down and the follow-up text
127
+ * it opens are two `survey sent` events answering two questions of the SAME
128
+ * response, so they must share this id. */
108
129
  submissionId: string;
109
130
  model?: string;
110
131
  browserSessionId?: string;
@@ -117,6 +138,10 @@ export interface AiFeedbackSurveyInput {
117
138
  * nowhere. Returns `false` and emits nothing when no survey id is configured:
118
139
  * inventing one would produce events attached to a survey that does not exist.
119
140
  *
141
+ * A thumbs vote answers the survey's first question; a category or free-text
142
+ * answer answers the follow-up question after it. Both share the caller's
143
+ * `submissionId`, which is how PostHog joins them into one response.
144
+ *
120
145
  * Sent to PostHog ONLY, not through `track()`. The survey response carries the
121
146
  * user's free-text feedback verbatim, and configuring a PostHog survey id must
122
147
  * not silently start shipping that text to Mixpanel, Amplitude, webhooks, or
@@ -24,10 +24,10 @@
24
24
  * schema, and a name it does not define today it may define tomorrow with
25
25
  * different meaning — `input_truncated` / `spans_dropped` cannot collide.
26
26
  *
27
- * Content (`$ai_input` / `$ai_output_choices` / `$ai_input_state` /
28
- * `$ai_output_state`) is gated on config and always OMITTED when disabled.
29
- * Sending `[]` instead would be indistinguishable from a run that genuinely had
30
- * no messages.
27
+ * Content (a generation's `$ai_input` / `$ai_output_choices`, a span's
28
+ * `$ai_input_state` / `$ai_output_state`) is gated on config and always OMITTED
29
+ * when disabled. Sending `[]` instead would be indistinguishable from a run
30
+ * that genuinely had no messages.
31
31
  *
32
32
  * @see https://posthog.com/docs/ai-observability/traces
33
33
  * @see https://posthog.com/docs/ai-observability/spans
@@ -38,6 +38,21 @@ import { boundedText } from "../tracking/redaction.js";
38
38
  export const MAX_AI_CONTENT_BYTES = 128 * 1024;
39
39
  /** Hard ceiling on emitted `$ai_span` events per run. */
40
40
  export const MAX_AI_SPANS_PER_RUN = 100;
41
+ /**
42
+ * `$ai_is_error: true` with no `$ai_error` is a dead end: the reader learns
43
+ * something broke and nothing else, which is how a failed tool span reached
44
+ * PostHog saying only "error". Emitters fall back to this rather than shipping
45
+ * the flag alone — a layer that failed without reporting a message is itself
46
+ * worth seeing, and is not the same as a message we chose to withhold.
47
+ */
48
+ const UNREPORTED_ERROR = {
49
+ message: "failed without a reported error message",
50
+ };
51
+ export function resolveAiError(isError, error) {
52
+ if (!isError)
53
+ return undefined;
54
+ return error ?? UNREPORTED_ERROR;
55
+ }
41
56
  function trackAiEvent(name, properties, userId, occurredAt) {
42
57
  for (const key of Object.keys(properties)) {
43
58
  if (properties[key] === undefined)
@@ -55,12 +70,24 @@ function trackAiEvent(name, properties, userId, occurredAt) {
55
70
  // Tracking must never affect the agent run or trace persistence.
56
71
  }
57
72
  }
73
+ function utf8Bytes(text) {
74
+ return typeof Buffer !== "undefined"
75
+ ? Buffer.byteLength(text, "utf8")
76
+ : new TextEncoder().encode(text).length;
77
+ }
78
+ /** Room reserved for the marker entry that reports what was dropped. */
79
+ const OMISSION_MARKER_BYTES = 512;
58
80
  /**
59
81
  * Serialize a content value under a byte ceiling.
60
82
  *
61
- * Returns `{ truncated: true }` with a placeholder rather than a silently
62
- * shortened payload — a trace that shows half a conversation as if it were the
63
- * whole one is worse than one that says it was cut.
83
+ * A message list keeps the last user message behind a marker entry naming what
84
+ * was dropped — what was asked is what a trace is opened for, and it stays
85
+ * small. Replacing the whole conversation with a placeholder, which is what
86
+ * this did, left nothing at all.
87
+ *
88
+ * Anything else (a string, an object) still becomes a placeholder: there is no
89
+ * message to keep. Either way `truncated` is true, so a cut payload can never
90
+ * be read as a complete one.
64
91
  */
65
92
  export function boundAiContent(value) {
66
93
  let serialized;
@@ -72,23 +99,45 @@ export function boundAiContent(value) {
72
99
  }
73
100
  if (serialized === undefined)
74
101
  return { value: undefined, truncated: false };
75
- const bytes = typeof Buffer !== "undefined"
76
- ? Buffer.byteLength(serialized, "utf8")
77
- : new TextEncoder().encode(serialized).length;
102
+ const bytes = utf8Bytes(serialized);
78
103
  if (bytes <= MAX_AI_CONTENT_BYTES)
79
104
  return { value, truncated: false };
105
+ if (Array.isArray(value)) {
106
+ // What was ASKED is the one thing worth rescuing from an oversized
107
+ // transcript: the rest is context the thread itself still holds, and
108
+ // keeping as much of it as fits just ships the ceiling on every event.
109
+ const lastUser = value.findLast((entry) => !!entry &&
110
+ typeof entry === "object" &&
111
+ entry.role === "user");
112
+ const kept = lastUser !== undefined &&
113
+ utf8Bytes(JSON.stringify(lastUser) ?? "null") <=
114
+ MAX_AI_CONTENT_BYTES - OMISSION_MARKER_BYTES
115
+ ? [lastUser]
116
+ : [];
117
+ return {
118
+ value: [
119
+ {
120
+ role: "system",
121
+ content: `[${value.length - kept.length} message(s) omitted: ${bytes} bytes exceeded the ${MAX_AI_CONTENT_BYTES}-byte trace content limit]`,
122
+ },
123
+ ...kept,
124
+ ],
125
+ truncated: true,
126
+ };
127
+ }
80
128
  return {
81
129
  value: `[truncated: ${bytes} bytes exceeded the ${MAX_AI_CONTENT_BYTES}-byte trace content limit]`,
82
130
  truncated: true,
83
131
  };
84
132
  }
133
+ /**
134
+ * The run's prompt and answer are NOT carried here. Every round-trip emits a
135
+ * generation with its own `$ai_input` / `$ai_output_choices`, so a trace-level
136
+ * copy repeated the first call's prompt and the last call's answer on a second
137
+ * event. The trace owns what only it knows: the run's name, failure, metadata
138
+ * and totals.
139
+ */
85
140
  export function emitAiTraceEvent(input) {
86
- const inputContent = input.inputState === undefined
87
- ? undefined
88
- : boundAiContent(input.inputState);
89
- const outputContent = input.outputState === undefined
90
- ? undefined
91
- : boundAiContent(input.outputState);
92
141
  trackAiEvent("$ai_trace", {
93
142
  ...input.extraProperties,
94
143
  $ai_trace_id: input.runId,
@@ -97,15 +146,14 @@ export function emitAiTraceEvent(input) {
97
146
  $ai_model: input.model,
98
147
  $ai_provider: input.provider,
99
148
  $ai_is_error: input.isError,
100
- $ai_error: input.error,
149
+ $ai_error: resolveAiError(input.isError, input.error),
150
+ $ai_error_type: input.isError
151
+ ? (input.errorType ?? "run_error")
152
+ : undefined,
101
153
  duration_ms: Math.round(input.durationMs),
102
154
  input_tokens: input.inputTokens,
103
155
  output_tokens: input.outputTokens,
104
156
  cost_usd: input.costUsd,
105
- $ai_input_state: inputContent?.value,
106
- $ai_output_state: outputContent?.value,
107
- input_truncated: inputContent?.truncated || undefined,
108
- output_truncated: outputContent?.truncated || undefined,
109
157
  $session_id: input.browserSessionId,
110
158
  created_at: new Date(input.createdAt).toISOString(),
111
159
  }, input.userId, input.createdAt);
@@ -126,7 +174,10 @@ export function emitAiSpanEvent(input) {
126
174
  $ai_span_name: input.spanName,
127
175
  $ai_latency: input.latencySeconds,
128
176
  $ai_is_error: input.isError,
129
- $ai_error: input.error,
177
+ $ai_error: resolveAiError(input.isError, input.error),
178
+ $ai_error_type: input.isError
179
+ ? (input.errorType ?? "tool_error")
180
+ : undefined,
130
181
  $ai_input_state: inputContent?.value,
131
182
  $ai_output_state: outputContent?.value,
132
183
  input_truncated: inputContent?.truncated || undefined,
@@ -135,6 +186,14 @@ export function emitAiSpanEvent(input) {
135
186
  created_at: new Date(input.createdAt).toISOString(),
136
187
  }, input.userId, input.createdAt);
137
188
  }
189
+ /**
190
+ * PostHog's thumbs survey is a choice question, and the response is the
191
+ * choice's 1-based index — not a label. Sending "thumbs_up" put a string where
192
+ * the rating belongs, so the vote counted as an unrecognized answer.
193
+ *
194
+ * @see https://posthog.com/docs/ai-observability/user-feedback/manual-event-capture
195
+ */
196
+ const THUMB_RESPONSE_INDEX = { thumbs_up: 1, thumbs_down: 2 };
138
197
  /**
139
198
  * Emit PostHog's documented manual feedback event for an LLM trace.
140
199
  *
@@ -143,6 +202,10 @@ export function emitAiSpanEvent(input) {
143
202
  * nowhere. Returns `false` and emits nothing when no survey id is configured:
144
203
  * inventing one would produce events attached to a survey that does not exist.
145
204
  *
205
+ * A thumbs vote answers the survey's first question; a category or free-text
206
+ * answer answers the follow-up question after it. Both share the caller's
207
+ * `submissionId`, which is how PostHog joins them into one response.
208
+ *
146
209
  * Sent to PostHog ONLY, not through `track()`. The survey response carries the
147
210
  * user's free-text feedback verbatim, and configuring a PostHog survey id must
148
211
  * not silently start shipping that text to Mixpanel, Amplitude, webhooks, or
@@ -155,17 +218,23 @@ export function emitAiFeedbackSurveyEvent(input) {
155
218
  const surveyId = process.env.POSTHOG_AI_FEEDBACK_SURVEY_ID?.trim();
156
219
  if (!surveyId)
157
220
  return false;
158
- const questionId = process.env.POSTHOG_AI_FEEDBACK_SURVEY_QUESTION_ID?.trim();
159
- // PostHog accepts `$survey_response` for a single-question survey and
160
- // `$survey_response_<questionId>` when the survey has named questions.
161
- const responseKey = questionId
162
- ? `$survey_response_${questionId}`
163
- : "$survey_response";
221
+ const thumbResponse = input.feedbackType === "thumbs_up" || input.feedbackType === "thumbs_down"
222
+ ? THUMB_RESPONSE_INDEX[input.feedbackType]
223
+ : undefined;
224
+ // `$survey_response` is the survey's first question — the thumbs — and
225
+ // `$survey_response_1` the follow-up asked after a thumbs-down. Putting the
226
+ // free text on the first key instead would file prose where the rating
227
+ // belongs and skew every thumbs metric in the survey.
228
+ const responses = thumbResponse !== undefined
229
+ ? { $survey_response: thumbResponse }
230
+ : { $survey_response_1: input.value };
164
231
  const properties = {
165
232
  $survey_id: surveyId,
166
- [responseKey]: input.value,
233
+ ...responses,
167
234
  $survey_submission_id: input.submissionId,
168
- $survey_completed: true,
235
+ // A thumbs-down opens the follow-up question, so the response is not
236
+ // complete until that answer arrives (or the user closes it without one).
237
+ $survey_completed: input.feedbackType !== "thumbs_down",
169
238
  $ai_trace_id: input.runId ?? undefined,
170
239
  $ai_session_id: input.threadId ?? undefined,
171
240
  $ai_model: input.model,
@@ -216,8 +216,15 @@ export function createObservabilityHandler() {
216
216
  threadId,
217
217
  userId: owner,
218
218
  feedbackType,
219
- value: isThumb ? feedbackType : value,
220
- submissionId: id,
219
+ value,
220
+ // One PostHog response per rated message, not per row: a thumbs-down
221
+ // and the free text it opens are two answers to the same survey, and
222
+ // a fresh id per row would file them as two unrelated responses.
223
+ // Falls back to the row id when there is no message to key on, which
224
+ // groups nothing — the honest outcome when nothing can be grouped.
225
+ submissionId: runId && typeof body.messageSeq === "number"
226
+ ? `${runId}:${body.messageSeq}`
227
+ : id,
221
228
  model,
222
229
  browserSessionId: getRequestContext()?.browserSessionId,
223
230
  });