@agent-native/core 0.172.3 → 0.172.4

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 (39) 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/slides/actions/list-decks.ts +79 -5
  18. package/corpus/templates/slides/app/components/deck/DeckCard.tsx +21 -1
  19. package/corpus/templates/slides/app/components/editor/ShareDialog.tsx +49 -21
  20. package/corpus/templates/slides/app/context/DeckContext.tsx +49 -21
  21. package/corpus/templates/slides/app/pages/DeckEditor.tsx +2 -2
  22. package/corpus/templates/slides/app/pages/Index.tsx +11 -13
  23. package/corpus/templates/slides/changelog/2026-08-24-deck-previews-and-sharing-live-in-the-overflow-menu.md +5 -0
  24. package/dist/agent/production-agent.js +5 -1
  25. package/dist/agent/types.d.ts +5 -0
  26. package/dist/collab/awareness.d.ts +2 -2
  27. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  28. package/dist/observability/posthog-ai.d.ts +36 -11
  29. package/dist/observability/posthog-ai.js +99 -30
  30. package/dist/observability/routes.d.ts +3 -3
  31. package/dist/observability/routes.js +9 -2
  32. package/dist/observability/traces.js +330 -142
  33. package/dist/progress/routes.d.ts +1 -1
  34. package/dist/resources/handlers.d.ts +1 -1
  35. package/dist/server/realtime-token.d.ts +1 -1
  36. package/dist/server/transcribe-voice.d.ts +1 -1
  37. package/dist/tracking/providers.js +9 -3
  38. package/docs/content/observability.mdx +27 -10
  39. package/package.json +3 -3
@@ -147,6 +147,8 @@ export interface Deck {
147
147
  starred?: boolean;
148
148
  /** Slide aspect ratio (defaults to 16:9 when absent for backwards compat) */
149
149
  aspectRatio?: AspectRatio;
150
+ /** First slide returned by the light deck listing for home-page previews. */
151
+ previewSlide?: Slide;
150
152
  }
151
153
 
152
154
  export type DeckPersistenceResult =
@@ -199,19 +201,18 @@ interface DeckContextType {
199
201
  ) => Deck;
200
202
  ensureDeckPersisted: (id: string) => Promise<DeckPersistenceResult>;
201
203
  /**
202
- * Optimistically duplicate a deck. Inserts a copy into local state with the
203
- * supplied `newId` immediately so the UI can navigate without awaiting the
204
- * server, then fires the duplicate-deck action in the background. On error,
205
- * the optimistic deck is rolled back.
204
+ * Duplicate a deck, hydrating a preview-only source before creating the
205
+ * optimistic copy. On error, the optimistic deck is rolled back.
206
206
  *
207
- * Returns the optimistic deck (or `null` if the source deck isn't found).
207
+ * Returns the optimistic deck (or `null` if the source deck isn't found or
208
+ * could not be hydrated).
208
209
  */
209
210
  duplicateDeck: (
210
211
  sourceDeckId: string,
211
212
  newId: string,
212
213
  title?: string,
213
214
  onFailure?: () => void,
214
- ) => Deck | null;
215
+ ) => Promise<Deck | null>;
215
216
  deleteDeck: (id: string) => void;
216
217
  updateDeck: (
217
218
  id: string,
@@ -325,6 +326,8 @@ function normalizeActionDeck(value: unknown): Deck | null {
325
326
  const deckRecord = deck as unknown as Record<string, unknown>;
326
327
  const cleanedDeck = { ...deckRecord };
327
328
  for (const field of GET_DECK_ONLY_DECK_FIELDS) delete cleanedDeck[field];
329
+ const previewSlide = deckRecord.previewSlide;
330
+ delete cleanedDeck.previewSlide;
328
331
 
329
332
  // Strip the same decorative fields from every slide, so a deck fetched from
330
333
  // `get-deck` is structurally identical to one built by local mutations —
@@ -356,9 +359,20 @@ function normalizeActionDeck(value: unknown): Deck | null {
356
359
  ? deck.updatedAt
357
360
  : deck.createdAt || "",
358
361
  slides,
362
+ ...(previewSlide && typeof previewSlide === "object"
363
+ ? { previewSlide: previewSlide as Slide }
364
+ : {}),
359
365
  } as Deck;
360
366
  }
361
367
 
368
+ export function getDuplicateSourceSlides(deck: Deck): Slide[] {
369
+ return deck.slides.length > 0
370
+ ? deck.slides
371
+ : deck.previewSlide
372
+ ? [deck.previewSlide]
373
+ : [];
374
+ }
375
+
362
376
  // Debounced save to API + save-state listeners (so the toolbar indicator
363
377
  // can show "Saving…" / "Saved"). The map tracks pending debounce timers;
364
378
  // `inFlight` tracks active fetches. Combined, they answer "is anything
@@ -1040,7 +1054,7 @@ async function fetchDecksFromAPI(): Promise<Deck[] | null> {
1040
1054
  try {
1041
1055
  const result = await callAction<DeckListActionResult>(
1042
1056
  "list-decks",
1043
- { light: "true" },
1057
+ { light: "true", includePreview: "true" },
1044
1058
  { method: "GET" },
1045
1059
  );
1046
1060
  if (!Array.isArray(result?.decks)) {
@@ -1172,7 +1186,7 @@ async function fetchDecksForCurrentRoute(): Promise<Deck[] | null> {
1172
1186
  }
1173
1187
  if (!currentOpenDeckId) return loaded;
1174
1188
 
1175
- // The list is intentionally metadata-only. Hydrate just the deck the user
1189
+ // The list has only first-slide previews. Hydrate just the deck the user
1176
1190
  // opened so the editor gets full slide content without making the home page
1177
1191
  // download every deck body.
1178
1192
  const directDeck = await fetchDeckFromAPI(currentOpenDeckId);
@@ -2143,15 +2157,27 @@ export function DeckProvider({ children }: { children: ReactNode }) {
2143
2157
  );
2144
2158
 
2145
2159
  const duplicateDeck = useCallback(
2146
- (
2160
+ async (
2147
2161
  sourceDeckId: string,
2148
2162
  newId: string,
2149
2163
  title?: string,
2150
2164
  onFailure?: () => void,
2151
- ): Deck | null => {
2165
+ ): Promise<Deck | null> => {
2152
2166
  if (pendingDuplicateSourceIdsRef.current.has(sourceDeckId)) return null;
2153
- const source = decks.find((d) => d.id === sourceDeckId);
2154
- if (!source) return null;
2167
+ pendingDuplicateSourceIdsRef.current.add(sourceDeckId);
2168
+ let source = decks.find((d) => d.id === sourceDeckId);
2169
+ if (!source) {
2170
+ pendingDuplicateSourceIdsRef.current.delete(sourceDeckId);
2171
+ return null;
2172
+ }
2173
+ if (source.slides.length === 0 && source.previewSlide) {
2174
+ const hydrated = await fetchDeckFromAPI(sourceDeckId);
2175
+ if (!hydrated) {
2176
+ pendingDuplicateSourceIdsRef.current.delete(sourceDeckId);
2177
+ return null;
2178
+ }
2179
+ source = hydrated;
2180
+ }
2155
2181
 
2156
2182
  const now = new Date().toISOString();
2157
2183
  const newTitle = title || `Copy of ${source.title}`;
@@ -2172,7 +2198,8 @@ export function DeckProvider({ children }: { children: ReactNode }) {
2172
2198
  createdByMe: true,
2173
2199
  shareToken: undefined,
2174
2200
  };
2175
- optimistic.slides = optimistic.slides.map((s) => ({
2201
+ delete optimistic.previewSlide;
2202
+ optimistic.slides = getDuplicateSourceSlides(source).map((s) => ({
2176
2203
  ...s,
2177
2204
  id: `slide-${nanoid(8)}`,
2178
2205
  }));
@@ -2181,7 +2208,6 @@ export function DeckProvider({ children }: { children: ReactNode }) {
2181
2208
  // the duplicate-deck action's INSERT lands.
2182
2209
  pendingCreateIdsRef.current.add(newId);
2183
2210
  noteLocalCreate(newId);
2184
- pendingDuplicateSourceIdsRef.current.add(sourceDeckId);
2185
2211
 
2186
2212
  // Fire the action in the background. On error, roll back.
2187
2213
  const duplicatePromise = callAction<DuplicateDeckActionResult>(
@@ -2190,10 +2216,12 @@ export function DeckProvider({ children }: { children: ReactNode }) {
2190
2216
  deckId: sourceDeckId,
2191
2217
  newId,
2192
2218
  title,
2193
- // The user can start editing the copy before this resolves, so the
2194
- // persisted slides must carry the ids the optimistic ones already
2195
- // have — otherwise those edits address slides the server never had.
2196
- slideIds: optimistic.slides.map((s) => s.id),
2219
+ // Preview-only sources were hydrated above. An empty array is not
2220
+ // a valid optimistic slide-id projection, so omit it for empty
2221
+ // decks and let the server generate ids for any uncovered slides.
2222
+ ...(optimistic.slides.length > 0
2223
+ ? { slideIds: optimistic.slides.map((s) => s.id) }
2224
+ : {}),
2197
2225
  },
2198
2226
  ).then(() => undefined);
2199
2227
  pendingCreatePromisesRef.current.set(newId, duplicatePromise);
@@ -2213,9 +2241,9 @@ export function DeckProvider({ children }: { children: ReactNode }) {
2213
2241
  }
2214
2242
  console.error("Duplicate failed:", err);
2215
2243
  // Roll back: drop the optimistic deck from local state. The caller
2216
- // (via onFailure) is responsible for navigating away if the user is
2217
- // still sitting on this now-gone deck's route — otherwise they're
2218
- // stranded on a "Deck unavailable" screen with no way back.
2244
+ // (via onFailure) is responsible for navigating away if the user
2245
+ // is still sitting on this now-gone deck's route — otherwise
2246
+ // they're stranded on a "Deck unavailable" screen with no way back.
2219
2247
  setDecks((prev) => prev.filter((d) => d.id !== newId));
2220
2248
  onFailure?.();
2221
2249
  })
@@ -1545,9 +1545,9 @@ export default function DeckEditor() {
1545
1545
  onTogglePinMode={togglePinMode}
1546
1546
  textBoxMode={textBoxMode}
1547
1547
  onToggleTextBoxMode={toggleTextBoxMode}
1548
- onDuplicateDeck={() => {
1548
+ onDuplicateDeck={async () => {
1549
1549
  const newId = `deck-${nanoid()}`;
1550
- const optimistic = duplicateDeck(id, newId, undefined, () => {
1550
+ const optimistic = await duplicateDeck(id, newId, undefined, () => {
1551
1551
  // The background duplicate-deck action failed after we already
1552
1552
  // navigated to the optimistic copy. If the user is still there,
1553
1553
  // send them back instead of stranding them on a "Deck
@@ -1289,19 +1289,17 @@ export default function Index() {
1289
1289
  // first (the same path the editor's own Duplicate uses) and navigate to
1290
1290
  // that; the background action reconciles or rolls the copy back.
1291
1291
  const handleDuplicate = useCallback(
1292
- (id: string) => {
1293
- let copy: ReturnType<typeof duplicateDeck> | undefined;
1294
- flushSync(() => {
1295
- copy = duplicateDeck(id, `deck-${nanoid()}`, undefined, () => {
1296
- // The background duplicate-deck action failed after we already
1297
- // navigated to the optimistic copy's route. If the user is still
1298
- // there, send them back to the deck list instead of stranding them
1299
- // on a "Deck unavailable" screen for a deck that no longer exists.
1300
- if (deckIdFromPathname(window.location.pathname) === copy?.id) {
1301
- navigate("/");
1302
- }
1303
- toast.error(t("home.duplicateFailed"));
1304
- });
1292
+ async (id: string) => {
1293
+ const newId = `deck-${nanoid()}`;
1294
+ const copy = await duplicateDeck(id, newId, undefined, () => {
1295
+ // The background duplicate-deck action failed after we already
1296
+ // navigated to the optimistic copy's route. If the user is still
1297
+ // there, send them back to the deck list instead of stranding them
1298
+ // on a "Deck unavailable" screen for a deck that no longer exists.
1299
+ if (deckIdFromPathname(window.location.pathname) === newId) {
1300
+ navigate("/");
1301
+ }
1302
+ toast.error(t("home.duplicateFailed"));
1305
1303
  });
1306
1304
  // The context refuses a second copy of the same deck while the first
1307
1305
  // one's action is still in flight.
@@ -0,0 +1,5 @@
1
+ ---
2
+ type: improved
3
+ date: 2026-08-24
4
+ ---
5
+ Decks now show a first-slide preview and keep sharing controls in the overflow menu.
@@ -3528,7 +3528,11 @@ export async function runAgentLoop(opts) {
3528
3528
  if (!modelStreamBracketOpen)
3529
3529
  return;
3530
3530
  modelStreamBracketOpen = false;
3531
- send({ type: "model_stream", status: "end" });
3531
+ send({
3532
+ type: "model_stream",
3533
+ status: "end",
3534
+ ...(terminalStopReason ? { reason: terminalStopReason } : {}),
3535
+ });
3532
3536
  };
3533
3537
  let lastModelStreamProgressAt = Date.now();
3534
3538
  // FIX 2: true once a real (non-heartbeat) engine-stream event has been
@@ -305,6 +305,11 @@ export type AgentChatEvent = {
305
305
  */
306
306
  type: "model_stream";
307
307
  status: "start" | "end";
308
+ /** Why the model stopped, on the closing event: `end_turn`, `tool_use`,
309
+ * `max_tokens`, `stop_sequence`, `error`. Absent when the stream was cut
310
+ * before the engine reported one — a truncated call and a call that
311
+ * ended cleanly must stay distinguishable. */
312
+ reason?: "end_turn" | "tool_use" | "max_tokens" | "stop_sequence" | "error";
308
313
  } | {
309
314
  type: "tool_start";
310
315
  tool: string;
@@ -62,11 +62,11 @@ export declare const postAwareness: import("h3").EventHandlerWithFetch<import("h
62
62
  error: string;
63
63
  states?: undefined;
64
64
  } | {
65
+ error?: undefined;
65
66
  states: {
66
67
  clientId: number;
67
68
  state: string;
68
69
  }[];
69
- error?: undefined;
70
70
  }>>;
71
71
  /**
72
72
  * GET /_agent-native/collab/:docId/users
@@ -77,9 +77,9 @@ export declare const getActiveUsers: import("h3").EventHandlerWithFetch<import("
77
77
  error: string;
78
78
  users?: undefined;
79
79
  } | {
80
+ error?: undefined;
80
81
  users: {
81
82
  clientId: number;
82
83
  lastSeen: number;
83
84
  }[];
84
- error?: undefined;
85
85
  }>>;
@@ -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;
@@ -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,
@@ -41,16 +41,16 @@ export declare function createObservabilityHandler(): import("h3").EventHandlerW
41
41
  thumbsUpRate: number;
42
42
  avgEvalScore: number;
43
43
  } | {
44
+ error?: undefined;
44
45
  summary: import("./types.js").TraceSummary;
45
46
  spans: import("./types.js").TraceSpan[];
46
47
  id?: undefined;
47
- error?: undefined;
48
48
  ok?: undefined;
49
49
  } | {
50
+ error?: undefined;
50
51
  summary?: undefined;
51
52
  spans?: undefined;
52
53
  id: string;
53
- error?: undefined;
54
54
  ok?: undefined;
55
55
  } | {
56
56
  summary?: undefined;
@@ -59,9 +59,9 @@ export declare function createObservabilityHandler(): import("h3").EventHandlerW
59
59
  error: any;
60
60
  ok?: undefined;
61
61
  } | {
62
+ error?: undefined;
62
63
  summary?: undefined;
63
64
  spans?: undefined;
64
65
  id?: undefined;
65
- error?: undefined;
66
66
  ok: boolean;
67
67
  }>>;
@@ -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
  });