@agent-native/core 0.161.1 → 0.161.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.
- package/corpus/templates/analytics/actions/compose-dashboard.ts +5 -2
- package/corpus/templates/analytics/actions/export-dashboard-panel-to-google-sheet.ts +11 -5
- package/corpus/templates/analytics/actions/migrate-first-party-analytics-to-bigquery.ts +89 -2
- package/corpus/templates/analytics/actions/update-dashboard.ts +14 -2
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/PanelEditorDialog.tsx +6 -0
- package/corpus/templates/analytics/server/lib/dashboard-panel-query.ts +89 -41
- package/corpus/templates/analytics/server/lib/dashboard-panel-source-resolver.ts +8 -3
- package/corpus/templates/analytics/server/lib/error-capture.ts +6 -0
- package/corpus/templates/analytics/server/lib/first-party-analytics-backend.ts +187 -44
- package/corpus/templates/analytics/server/lib/first-party-analytics.ts +44 -16
- package/corpus/templates/clips/actions/save-browser-transcript.ts +20 -4
- package/corpus/templates/clips/app/components/meetings/transcript-bubbles.tsx +167 -35
- package/corpus/templates/clips/desktop/src/lib/transcription-capture.ts +8 -1
- package/corpus/templates/clips/desktop/src/lib/transcription-engine.ts +39 -2
- package/corpus/templates/slides/actions/list-decks.ts +36 -1
- package/corpus/templates/slides/app/context/DeckContext.tsx +17 -6
- package/dist/agent/engine/builder-engine.js +37 -19
- package/dist/agent/engine/types.d.ts +29 -0
- package/dist/agent/engine/types.js +6 -0
- package/dist/agent/production-agent.js +2 -0
- package/dist/agent/run-manager.d.ts +6 -0
- package/dist/agent/run-manager.js +27 -0
- package/dist/agent/run-store.d.ts +5 -5
- package/dist/agent/run-store.js +52 -15
- package/dist/client/AssistantChat.d.ts +1 -0
- package/dist/client/AssistantChat.js +10 -1
- package/dist/db/client.js +10 -2
- package/dist/db/create-get-db.js +42 -0
- package/dist/observability/routes.d.ts +3 -3
- package/dist/provider-api/actions/custom-provider-registration.d.ts +2 -2
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/onboarding-html.js +22 -77
- package/dist/server/poll.d.ts +5 -5
- package/dist/server/poll.js +19 -26
- package/dist/server/release-migrations.js +4 -0
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/shared/auth-copy.d.ts +7 -0
- package/dist/shared/auth-copy.js +77 -0
- package/dist/shared/mcp-embed-headers.js +8 -4
- package/dist/vite/client.js +94 -3
- package/dist/workspace-connections/migrations.d.ts +18 -0
- package/dist/workspace-connections/migrations.js +153 -0
- package/package.json +1 -1
|
@@ -61,8 +61,99 @@ export interface SpeakerIdentity {
|
|
|
61
61
|
initialsSource: AttendeeStackParticipant | string;
|
|
62
62
|
isOwner: boolean;
|
|
63
63
|
accentClass: string;
|
|
64
|
+
/** The capture could not tell speakers apart, so this transcript names
|
|
65
|
+
* nobody — the row renders without an avatar or label rather than claiming
|
|
66
|
+
* a speaker we cannot know. */
|
|
67
|
+
unattributed?: boolean;
|
|
64
68
|
}
|
|
65
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Whether a transcript carries enough signal to name who said what.
|
|
72
|
+
*
|
|
73
|
+
* A capture that only ever produced one speaker signal cannot distinguish two
|
|
74
|
+
* people: the mic-only fallback engines tag every segment `mic` (the remote
|
|
75
|
+
* side reaches the transcript only as bleed into the same microphone), and
|
|
76
|
+
* cloud transcription of a single mixed track tags nothing at all. Attributing
|
|
77
|
+
* those to the recording owner reads as fact and is wrong for every line the
|
|
78
|
+
* other person spoke — including in the AI summary and action items derived
|
|
79
|
+
* from it. A per-segment `speaker` label from a diarizing provider counts as
|
|
80
|
+
* signal even when `source` is absent.
|
|
81
|
+
*
|
|
82
|
+
* Only meaningful when two people could have spoken; a solo recording that is
|
|
83
|
+
* all mic genuinely is all one person.
|
|
84
|
+
*/
|
|
85
|
+
export function transcriptDistinguishesSpeakers(
|
|
86
|
+
segments: TranscriptSegment[],
|
|
87
|
+
participants: AttendeeStackParticipant[],
|
|
88
|
+
ownerEmail?: string | null,
|
|
89
|
+
): boolean {
|
|
90
|
+
if (countPossibleSpeakers(participants, ownerEmail) < 2) return true;
|
|
91
|
+
const signals = new Set<string>();
|
|
92
|
+
for (const segment of segments) {
|
|
93
|
+
const signal = speakerSignal(segment);
|
|
94
|
+
if (signal) signals.add(signal);
|
|
95
|
+
if (signals.size > 1) return true;
|
|
96
|
+
}
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* What one segment claims about who was speaking, as a comparable key.
|
|
102
|
+
*
|
|
103
|
+
* A generic placeholder is not an identity — it names a side of the
|
|
104
|
+
* conversation, which is what `source` already says. Counting `speaker: "Me"`
|
|
105
|
+
* and a plain `source: "mic"` as two different speakers would mark a mic-only
|
|
106
|
+
* transcript distinguishable and hand the remote side's bleed back to the
|
|
107
|
+
* owner's name, so placeholders resolve to the side they mean instead. A real
|
|
108
|
+
* name wins over `source`, since a diarizing provider knows more than the
|
|
109
|
+
* stream split does; a placeholder yields to it.
|
|
110
|
+
*/
|
|
111
|
+
function speakerSignal(segment: TranscriptSegment): string | null {
|
|
112
|
+
const speaker = segment.speaker?.trim();
|
|
113
|
+
const side = placeholderSide(speaker);
|
|
114
|
+
if (speaker && !side) return `speaker:${normalizeSpeaker(speaker)}`;
|
|
115
|
+
if (segment.source) return `source:${segment.source}`;
|
|
116
|
+
// No placeholder and no source is an absence of information, not a side.
|
|
117
|
+
// Defaulting it to "system" here would make a transcript that mixes tagged
|
|
118
|
+
// and untagged segments look like two speakers.
|
|
119
|
+
return side ? `source:${side}` : null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* How many people could have spoken in this meeting.
|
|
124
|
+
*
|
|
125
|
+
* The participant roster is the calendar attendee list, which routinely omits
|
|
126
|
+
* the recording owner — `create-meeting` deliberately does not synthesize a row
|
|
127
|
+
* for a non-attendee owner, because that table feeds the public share payload.
|
|
128
|
+
* So counting rows alone reads an owner-plus-one-attendee meeting as solo and
|
|
129
|
+
* hands a mic-only transcript back to attribution, which is what labels the
|
|
130
|
+
* remote side's bleed as the owner.
|
|
131
|
+
*
|
|
132
|
+
* A withheld owner (`null`, from the public share page) still counts: it means
|
|
133
|
+
* an owner exists and is not among the participants. An owner we were never
|
|
134
|
+
* told about (`undefined`) also counts, because "we cannot name them" is not
|
|
135
|
+
* the same as "they are not there" — the cost of over-counting is a lost label,
|
|
136
|
+
* and the cost of under-counting is a false one.
|
|
137
|
+
*/
|
|
138
|
+
function countPossibleSpeakers(
|
|
139
|
+
participants: AttendeeStackParticipant[],
|
|
140
|
+
ownerEmail?: string | null,
|
|
141
|
+
): number {
|
|
142
|
+
const ownerInRoster = ownerEmail
|
|
143
|
+
? Boolean(findParticipant(ownerEmail, participants))
|
|
144
|
+
: false;
|
|
145
|
+
return participants.length + (ownerInRoster ? 0 : 1);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const UNATTRIBUTED_SPEAKER: SpeakerIdentity = {
|
|
149
|
+
key: "unattributed",
|
|
150
|
+
label: null,
|
|
151
|
+
initialsSource: "",
|
|
152
|
+
isOwner: false,
|
|
153
|
+
accentClass: "",
|
|
154
|
+
unattributed: true,
|
|
155
|
+
};
|
|
156
|
+
|
|
66
157
|
const SPEAKER_ACCENTS = [
|
|
67
158
|
"bg-accent text-accent-foreground",
|
|
68
159
|
"bg-secondary text-secondary-foreground",
|
|
@@ -75,6 +166,37 @@ function normalizeSpeaker(value: string): string {
|
|
|
75
166
|
return value.trim().toLowerCase().replace(/\s+/g, " ");
|
|
76
167
|
}
|
|
77
168
|
|
|
169
|
+
// Some providers (and our own seed fixture) tag unresolved segments with a
|
|
170
|
+
// literal placeholder word instead of leaving `speaker` blank. These name a
|
|
171
|
+
// side of the conversation rather than a person, so neither the label nor the
|
|
172
|
+
// attribution check may treat one as an identity.
|
|
173
|
+
const GENERIC_MIC_SPEAKER = /^(me|self|you)$/i;
|
|
174
|
+
const GENERIC_SYSTEM_SPEAKER = /^them$/i;
|
|
175
|
+
|
|
176
|
+
/** The side a generic placeholder names, or null when it names a person. */
|
|
177
|
+
function placeholderSide(
|
|
178
|
+
label: string | null | undefined,
|
|
179
|
+
): "mic" | "system" | null {
|
|
180
|
+
const speaker = label?.trim();
|
|
181
|
+
if (!speaker) return null;
|
|
182
|
+
if (GENERIC_MIC_SPEAKER.test(speaker)) return "mic";
|
|
183
|
+
if (GENERIC_SYSTEM_SPEAKER.test(speaker)) return "system";
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Which side of the conversation a segment belongs to.
|
|
189
|
+
*
|
|
190
|
+
* `source` is the real signal, but a segment can arrive with only a
|
|
191
|
+
* placeholder speaker on it. Falling straight through to "system" then drops
|
|
192
|
+
* every "Me" into the remote group, which is how a transcript labelled purely
|
|
193
|
+
* with placeholders renders entirely as "Them" — the original bug. Read the
|
|
194
|
+
* side the placeholder names before defaulting.
|
|
195
|
+
*/
|
|
196
|
+
function segmentSide(segment: TranscriptSegment): "mic" | "system" {
|
|
197
|
+
return segment.source ?? placeholderSide(segment.speaker) ?? "system";
|
|
198
|
+
}
|
|
199
|
+
|
|
78
200
|
// Exported for regression testing — see transcript-bubbles.test.ts. These
|
|
79
201
|
// are pure functions with no dependency on the component itself.
|
|
80
202
|
export function findParticipant(
|
|
@@ -139,22 +261,23 @@ export function resolveSpeaker(
|
|
|
139
261
|
participants: AttendeeStackParticipant[],
|
|
140
262
|
ownerEmail?: string | null,
|
|
141
263
|
): SpeakerIdentity {
|
|
142
|
-
const source = segment
|
|
264
|
+
const source = segmentSide(segment);
|
|
143
265
|
const rawSpeaker = segment.speaker?.trim();
|
|
144
266
|
const participant =
|
|
145
267
|
findParticipant(segment.speaker, participants) ||
|
|
146
268
|
resolveParticipantForSpeaker(source, participants, ownerEmail);
|
|
147
269
|
const participantName = participant?.name?.trim();
|
|
148
270
|
const resolvedLabel = participantName || rawSpeaker;
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
271
|
+
// Treat a placeholder as "no label" so the UI falls back to the translated
|
|
272
|
+
// Me/Them string instead of rendering the raw English word verbatim. Only
|
|
273
|
+
// the placeholder matching this segment's own side counts: "Them" on a mic
|
|
274
|
+
// segment is a contradiction, not a placeholder, and keeping it visible is
|
|
275
|
+
// better than silently dropping it.
|
|
153
276
|
const isGenericPlaceholderLabel =
|
|
154
277
|
!!resolvedLabel &&
|
|
155
278
|
(source === "mic"
|
|
156
|
-
?
|
|
157
|
-
:
|
|
279
|
+
? GENERIC_MIC_SPEAKER.test(resolvedLabel)
|
|
280
|
+
: GENERIC_SYSTEM_SPEAKER.test(resolvedLabel));
|
|
158
281
|
const label = isGenericPlaceholderLabel
|
|
159
282
|
? null
|
|
160
283
|
: participantName || rawSpeaker || null;
|
|
@@ -203,9 +326,16 @@ function groupConsecutive(
|
|
|
203
326
|
participants: AttendeeStackParticipant[],
|
|
204
327
|
ownerEmail?: string | null,
|
|
205
328
|
): BubbleGroup[] {
|
|
329
|
+
const attributable = transcriptDistinguishesSpeakers(
|
|
330
|
+
segments,
|
|
331
|
+
participants,
|
|
332
|
+
ownerEmail,
|
|
333
|
+
);
|
|
206
334
|
const groups: BubbleGroup[] = [];
|
|
207
335
|
segments.forEach((seg, index) => {
|
|
208
|
-
const speaker =
|
|
336
|
+
const speaker = attributable
|
|
337
|
+
? resolveSpeaker(seg, participants, ownerEmail)
|
|
338
|
+
: UNATTRIBUTED_SPEAKER;
|
|
209
339
|
const last = groups[groups.length - 1];
|
|
210
340
|
if (last && last.speaker.key === speaker.key) {
|
|
211
341
|
last.segments.push({ seg, index });
|
|
@@ -509,38 +639,40 @@ export function TranscriptBubbles({
|
|
|
509
639
|
key={`${group.speaker.key}:${gi}`}
|
|
510
640
|
className="space-y-0.5"
|
|
511
641
|
>
|
|
512
|
-
|
|
513
|
-
<
|
|
514
|
-
|
|
515
|
-
"size-6 shrink-0",
|
|
516
|
-
group.speaker.accentClass,
|
|
517
|
-
)}
|
|
518
|
-
>
|
|
519
|
-
<AvatarFallback
|
|
642
|
+
{!group.speaker.unattributed && (
|
|
643
|
+
<div className="flex h-6 items-center gap-2">
|
|
644
|
+
<Avatar
|
|
520
645
|
className={cn(
|
|
521
|
-
"
|
|
646
|
+
"size-6 shrink-0",
|
|
522
647
|
group.speaker.accentClass,
|
|
523
648
|
)}
|
|
524
649
|
>
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
650
|
+
<AvatarFallback
|
|
651
|
+
className={cn(
|
|
652
|
+
"text-[9px] font-semibold",
|
|
653
|
+
group.speaker.accentClass,
|
|
654
|
+
)}
|
|
655
|
+
>
|
|
656
|
+
{attendeeInitials(group.speaker.initialsSource)}
|
|
657
|
+
</AvatarFallback>
|
|
658
|
+
</Avatar>
|
|
659
|
+
<div className="flex min-h-6 items-center">
|
|
660
|
+
<span
|
|
661
|
+
className={cn(
|
|
662
|
+
"text-xs font-semibold leading-6",
|
|
663
|
+
group.speaker.isOwner
|
|
664
|
+
? "text-primary"
|
|
665
|
+
: "text-foreground",
|
|
666
|
+
)}
|
|
667
|
+
>
|
|
668
|
+
{group.speaker.label ||
|
|
669
|
+
(group.speaker.isOwner
|
|
670
|
+
? t("transcriptBubbles.me")
|
|
671
|
+
: t("transcriptBubbles.them"))}
|
|
672
|
+
</span>
|
|
673
|
+
</div>
|
|
542
674
|
</div>
|
|
543
|
-
|
|
675
|
+
)}
|
|
544
676
|
<div className="space-y-1">
|
|
545
677
|
{group.segments.map(({ seg, index }) => {
|
|
546
678
|
return (
|
|
@@ -36,7 +36,9 @@ function wait(ms: number): Promise<void> {
|
|
|
36
36
|
export interface CapturedTranscript {
|
|
37
37
|
/** Speaker-labelled text, lines joined by blank lines. */
|
|
38
38
|
text: string;
|
|
39
|
-
/**
|
|
39
|
+
/** Whisper's verbatim timestamps where the engine reported them, else one
|
|
40
|
+
* synthesized segment per line — the mic-only engines report no timings,
|
|
41
|
+
* and a dropped line would lose its speaker along with its text. */
|
|
40
42
|
segments: SourcedTranscriptSegment[];
|
|
41
43
|
/** Source stored with `save-browser-transcript`. */
|
|
42
44
|
source?: "web-speech" | "macos-native" | "whisper";
|
|
@@ -332,9 +334,14 @@ export async function startTranscriptionCapture(
|
|
|
332
334
|
});
|
|
333
335
|
};
|
|
334
336
|
|
|
337
|
+
// `source` reports the engine that actually produced this transcript, not
|
|
338
|
+
// the one we asked for: `startTranscriptionEngine` may have fallen back to
|
|
339
|
+
// mic-only macos-native. Omitting it made the server default to "whisper"
|
|
340
|
+
// and treat a mic-only capture as mixed mic + system audio.
|
|
335
341
|
const captured = (): CapturedTranscript => ({
|
|
336
342
|
text: transcriptFullText(lines),
|
|
337
343
|
segments: transcriptSegments(lines),
|
|
344
|
+
source: engine,
|
|
338
345
|
});
|
|
339
346
|
|
|
340
347
|
let engine: TranscriptionEngine;
|
|
@@ -245,11 +245,48 @@ export function transcriptFullText(lines: TranscriptLine[]): string {
|
|
|
245
245
|
.trim();
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
-
/**
|
|
248
|
+
/** Rough duration estimate for a line with no verbatim timing, matching the
|
|
249
|
+
* pacing `buildCaptionSegmentsFromText` uses server-side. */
|
|
250
|
+
function estimatedLineDurationMs(text: string): number {
|
|
251
|
+
const words = text.split(/\s+/).filter(Boolean).length || 1;
|
|
252
|
+
return Math.max(900, words * 420);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Flattened verbatim segments, as persisted alongside the text.
|
|
256
|
+
*
|
|
257
|
+
* A line with no verbatim timings still contributes one synthesized segment
|
|
258
|
+
* rather than being dropped: the mic-only fallback engines report no
|
|
259
|
+
* timestamps at all, and dropping the line loses its text and its `source`
|
|
260
|
+
* — and a `source`-less stored segment silently renders as "Them". Those
|
|
261
|
+
* engines report `startMs: null` on *every* line, so a synthesized segment
|
|
262
|
+
* continues from the previous line's end; anchoring each at its own
|
|
263
|
+
* null-coalesced 0 would stack the whole transcript on one instant and break
|
|
264
|
+
* ordering and timestamp seeking. */
|
|
249
265
|
export function transcriptSegments(
|
|
250
266
|
lines: TranscriptLine[],
|
|
251
267
|
): SourcedTranscriptSegment[] {
|
|
252
|
-
|
|
268
|
+
const segments: SourcedTranscriptSegment[] = [];
|
|
269
|
+
let cursorMs = 0;
|
|
270
|
+
for (const line of lines) {
|
|
271
|
+
if (line.segments.length) {
|
|
272
|
+
segments.push(...line.segments);
|
|
273
|
+
cursorMs = line.segments.reduce(
|
|
274
|
+
(latest, segment) => Math.max(latest, segment.endMs),
|
|
275
|
+
cursorMs,
|
|
276
|
+
);
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
// `appendFinalTranscript` is the only path that yields an untimed line and
|
|
280
|
+
// it always pairs `segments: []` with `startMs: null`, so today this reads
|
|
281
|
+
// as `cursorMs`. Honouring a line's own stamp is for the shape the type
|
|
282
|
+
// still permits (see `historyLine` in overlays/live-transcript.tsx), and
|
|
283
|
+
// clamping to the cursor keeps a stale stamp from reordering the transcript.
|
|
284
|
+
const startMs = Math.max(line.startMs ?? cursorMs, cursorMs);
|
|
285
|
+
const endMs = startMs + estimatedLineDurationMs(line.text);
|
|
286
|
+
segments.push({ startMs, endMs, text: line.text, source: line.source });
|
|
287
|
+
cursorMs = endMs;
|
|
288
|
+
}
|
|
289
|
+
return segments;
|
|
253
290
|
}
|
|
254
291
|
|
|
255
292
|
/**
|
|
@@ -22,7 +22,9 @@ export default defineAction({
|
|
|
22
22
|
includeSlides: z
|
|
23
23
|
.enum(["true", "false"])
|
|
24
24
|
.optional()
|
|
25
|
-
.describe(
|
|
25
|
+
.describe(
|
|
26
|
+
"Set to 'true' for full frontend deck payloads; omitted returns metadata only",
|
|
27
|
+
),
|
|
26
28
|
light: z
|
|
27
29
|
.enum(["true", "false"])
|
|
28
30
|
.optional()
|
|
@@ -84,6 +86,39 @@ export default defineAction({
|
|
|
84
86
|
return { count: rows.length, decks: rows };
|
|
85
87
|
}
|
|
86
88
|
|
|
89
|
+
if (args.includeSlides !== "true") {
|
|
90
|
+
// The deck body is an opaque JSON blob containing every slide's HTML.
|
|
91
|
+
// Metadata callers must opt into it explicitly; the frontend opens one
|
|
92
|
+
// deck at a time through get-deck instead of downloading every body.
|
|
93
|
+
const rows = await db
|
|
94
|
+
.select({
|
|
95
|
+
id: schema.decks.id,
|
|
96
|
+
title: schema.decks.title,
|
|
97
|
+
ownerEmail: schema.decks.ownerEmail,
|
|
98
|
+
designSystemId: schema.decks.designSystemId,
|
|
99
|
+
createdAt: schema.decks.createdAt,
|
|
100
|
+
updatedAt: schema.decks.updatedAt,
|
|
101
|
+
visibility: schema.decks.visibility,
|
|
102
|
+
})
|
|
103
|
+
.from(schema.decks)
|
|
104
|
+
.where(where)
|
|
105
|
+
.orderBy(desc(schema.decks.updatedAt));
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
count: rows.length,
|
|
109
|
+
decks: rows.map((row) => ({
|
|
110
|
+
id: row.id,
|
|
111
|
+
title: row.title,
|
|
112
|
+
url: getDeckUrl(row.id),
|
|
113
|
+
visibility: row.visibility,
|
|
114
|
+
designSystemId: row.designSystemId ?? null,
|
|
115
|
+
createdByMe: ownerEmail ? row.ownerEmail === ownerEmail : false,
|
|
116
|
+
createdAt: row.createdAt,
|
|
117
|
+
updatedAt: row.updatedAt,
|
|
118
|
+
})),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
87
122
|
const rows = await db
|
|
88
123
|
.select()
|
|
89
124
|
.from(schema.decks)
|
|
@@ -940,7 +940,7 @@ export function deriveInverseOp(
|
|
|
940
940
|
}
|
|
941
941
|
|
|
942
942
|
/**
|
|
943
|
-
* Fetch the deck list. Returns `null` on any failure (network error, non-2xx
|
|
943
|
+
* Fetch the deck metadata list. Returns `null` on any failure (network error, non-2xx
|
|
944
944
|
* response) so callers can distinguish "authoritative empty list" from
|
|
945
945
|
* "couldn't reach the server" — wiping local state on a transient failure
|
|
946
946
|
* kicks the user out of the editor and shows the "Create your first deck"
|
|
@@ -951,7 +951,7 @@ async function fetchDecksFromAPI(): Promise<Deck[] | null> {
|
|
|
951
951
|
try {
|
|
952
952
|
const result = await callAction<DeckListActionResult>(
|
|
953
953
|
"list-decks",
|
|
954
|
-
{
|
|
954
|
+
{ light: "true" },
|
|
955
955
|
{ method: "GET" },
|
|
956
956
|
);
|
|
957
957
|
if (!Array.isArray(result?.decks)) {
|
|
@@ -1076,12 +1076,23 @@ export async function includeOpenDeckIfMissing(
|
|
|
1076
1076
|
async function fetchDecksForCurrentRoute(): Promise<Deck[] | null> {
|
|
1077
1077
|
const currentOpenDeckId = currentOpenDeckIdFromWindow();
|
|
1078
1078
|
const loaded = await fetchDecksFromAPI();
|
|
1079
|
-
if (loaded
|
|
1080
|
-
|
|
1079
|
+
if (loaded === null) {
|
|
1080
|
+
if (!currentOpenDeckId) return null;
|
|
1081
|
+
const directDeck = await fetchDeckFromAPI(currentOpenDeckId);
|
|
1082
|
+
return directDeck ? [directDeck] : null;
|
|
1081
1083
|
}
|
|
1082
|
-
if (!currentOpenDeckId) return
|
|
1084
|
+
if (!currentOpenDeckId) return loaded;
|
|
1085
|
+
|
|
1086
|
+
// The list is intentionally metadata-only. Hydrate just the deck the user
|
|
1087
|
+
// opened so the editor gets full slide content without making the home page
|
|
1088
|
+
// download every deck body.
|
|
1083
1089
|
const directDeck = await fetchDeckFromAPI(currentOpenDeckId);
|
|
1084
|
-
|
|
1090
|
+
if (!directDeck) return loaded;
|
|
1091
|
+
const index = loaded.findIndex((deck) => deck.id === currentOpenDeckId);
|
|
1092
|
+
if (index < 0) return [...loaded, directDeck];
|
|
1093
|
+
const next = [...loaded];
|
|
1094
|
+
next[index] = directDeck;
|
|
1095
|
+
return next;
|
|
1085
1096
|
}
|
|
1086
1097
|
|
|
1087
1098
|
async function deleteDeckFromAPI(id: string): Promise<void> {
|
|
@@ -226,6 +226,17 @@ class BuilderEngine {
|
|
|
226
226
|
}
|
|
227
227
|
: {}),
|
|
228
228
|
};
|
|
229
|
+
// Measured once, from the exact string that goes on the wire, and carried
|
|
230
|
+
// on every error stop below. A gateway rejection tells us nothing about
|
|
231
|
+
// what we sent, so without this an oversized or malformed request and a
|
|
232
|
+
// gateway outage are the same capture.
|
|
233
|
+
const payload = JSON.stringify(body);
|
|
234
|
+
const requestShape = {
|
|
235
|
+
model: opts.model,
|
|
236
|
+
payloadBytes: new TextEncoder().encode(payload).length,
|
|
237
|
+
toolCount: cachedTools.length,
|
|
238
|
+
messageCount: cachedMessages.length,
|
|
239
|
+
};
|
|
229
240
|
const gatewayBaseUrl = getBuilderGatewayBaseUrl();
|
|
230
241
|
const gatewayUrl = new URL("messages", gatewayBaseUrl.endsWith("/") ? gatewayBaseUrl : `${gatewayBaseUrl}/`);
|
|
231
242
|
gatewayUrl.searchParams.set("apiKey", spaceId);
|
|
@@ -246,7 +257,7 @@ class BuilderEngine {
|
|
|
246
257
|
...getBuilderGatewayRequestHeaders(),
|
|
247
258
|
...(builderUserId ? { "x-builder-user-id": builderUserId } : {}),
|
|
248
259
|
},
|
|
249
|
-
body:
|
|
260
|
+
body: payload,
|
|
250
261
|
signal: gatewayAbort.signal,
|
|
251
262
|
});
|
|
252
263
|
}
|
|
@@ -265,12 +276,12 @@ class BuilderEngine {
|
|
|
265
276
|
elapsedMs: Date.now() - tStart,
|
|
266
277
|
});
|
|
267
278
|
}
|
|
268
|
-
yield createBuilderGatewayTimeoutStop(err, timedOut, gatewayAbort.effectiveTimeoutMs(), creditsLane);
|
|
279
|
+
yield createBuilderGatewayTimeoutStop(err, timedOut, gatewayAbort.effectiveTimeoutMs(), creditsLane, requestShape);
|
|
269
280
|
return;
|
|
270
281
|
}
|
|
271
282
|
console.log(`[builder-engine] ← ${response.status} ${response.statusText} in ${Date.now() - tStart}ms`);
|
|
272
283
|
if (!response.ok) {
|
|
273
|
-
yield* emitHttpError(response, { creditsLane });
|
|
284
|
+
yield* emitHttpError(response, { creditsLane, requestShape });
|
|
274
285
|
return;
|
|
275
286
|
}
|
|
276
287
|
// A successful gateway call proves the connected credentials are valid
|
|
@@ -302,7 +313,7 @@ class BuilderEngine {
|
|
|
302
313
|
...(isTransientGatewayFailure(error, status)
|
|
303
314
|
? { providerRetryable: true }
|
|
304
315
|
: {}),
|
|
305
|
-
}, creditsLane);
|
|
316
|
+
}, creditsLane, requestShape);
|
|
306
317
|
return;
|
|
307
318
|
}
|
|
308
319
|
const reader = response.body?.getReader();
|
|
@@ -311,7 +322,7 @@ class BuilderEngine {
|
|
|
311
322
|
error: "Builder gateway response has no body",
|
|
312
323
|
errorCode: "builder_gateway_error",
|
|
313
324
|
statusCode: response.status,
|
|
314
|
-
}, creditsLane);
|
|
325
|
+
}, creditsLane, requestShape);
|
|
315
326
|
return;
|
|
316
327
|
}
|
|
317
328
|
yield* parseJsonlStream(reader, opts.model, {
|
|
@@ -322,6 +333,7 @@ class BuilderEngine {
|
|
|
322
333
|
onFirstEvent: gatewayAbort.markFirstEvent,
|
|
323
334
|
gatewayUrl,
|
|
324
335
|
requestStartedAt: tStart,
|
|
336
|
+
requestShape,
|
|
325
337
|
});
|
|
326
338
|
}
|
|
327
339
|
finally {
|
|
@@ -358,7 +370,7 @@ function isTransientGatewayFailure(rawMessage, status) {
|
|
|
358
370
|
* signals downstream may read: keyword coupling to the message turns a retryable
|
|
359
371
|
* throttle into a dead turn on credits sites alone.
|
|
360
372
|
*/
|
|
361
|
-
function gatewayErrorStop(details, creditsLane) {
|
|
373
|
+
function gatewayErrorStop(details, creditsLane, requestShape) {
|
|
362
374
|
const { error, errorCode, upgradeUrl, ...retry } = details;
|
|
363
375
|
return {
|
|
364
376
|
type: "stop",
|
|
@@ -373,6 +385,9 @@ function gatewayErrorStop(details, creditsLane) {
|
|
|
373
385
|
...(isContextOverflowMessage(error) || isContextOverflowCode(errorCode)
|
|
374
386
|
? { contextOverflow: true }
|
|
375
387
|
: {}),
|
|
388
|
+
// Absent before the request is built (missing credentials): a stop with no
|
|
389
|
+
// shape means nothing was sent, not that the payload measured zero.
|
|
390
|
+
...(requestShape ? { requestShape } : {}),
|
|
376
391
|
...retry,
|
|
377
392
|
};
|
|
378
393
|
}
|
|
@@ -394,7 +409,7 @@ async function* emitHttpError(response, opts) {
|
|
|
394
409
|
}
|
|
395
410
|
const code = errBody.code ?? `http_${status}`;
|
|
396
411
|
const message = errBody.message ?? `Builder gateway returned ${status}`;
|
|
397
|
-
const stop = (details) => gatewayErrorStop(details, opts.creditsLane);
|
|
412
|
+
const stop = (details) => gatewayErrorStop(details, opts.creditsLane, opts.requestShape);
|
|
398
413
|
// Belt-and-suspenders: 402 without a structured `credits-limit` code
|
|
399
414
|
// (e.g. bare proxy response) still means quota → show upgrade CTA.
|
|
400
415
|
if (code.startsWith("credits-limit") || status === 402) {
|
|
@@ -543,7 +558,7 @@ async function* parseJsonlStream(reader, model, captureContext = {}) {
|
|
|
543
558
|
errorCode: "http_502",
|
|
544
559
|
statusCode: 502,
|
|
545
560
|
providerRetryable: true,
|
|
546
|
-
}, captureContext.creditsLane);
|
|
561
|
+
}, captureContext.creditsLane, captureContext.requestShape);
|
|
547
562
|
return;
|
|
548
563
|
}
|
|
549
564
|
// Heartbeats are transport-level keepalives, not proof the model is
|
|
@@ -624,7 +639,7 @@ async function* parseJsonlStream(reader, model, captureContext = {}) {
|
|
|
624
639
|
yield* recoverUndeliveredToolCalls();
|
|
625
640
|
yield { type: "assistant-content", parts };
|
|
626
641
|
const reason = event.reason ?? "end_turn";
|
|
627
|
-
const stop = (details) => gatewayErrorStop(details, captureContext.creditsLane);
|
|
642
|
+
const stop = (details) => gatewayErrorStop(details, captureContext.creditsLane, captureContext.requestShape);
|
|
628
643
|
if (reason === "rate_limited") {
|
|
629
644
|
yield stop({
|
|
630
645
|
error: `rate_limit exceeded: ${event.error ?? "upstream provider rate limited"}`,
|
|
@@ -655,6 +670,7 @@ async function* parseJsonlStream(reader, model, captureContext = {}) {
|
|
|
655
670
|
const explicitErrMsg = event.error || event.message || event.detail;
|
|
656
671
|
const errMsg = explicitErrMsg ??
|
|
657
672
|
`Gateway error (no detail; raw event: ${JSON.stringify(event)})`;
|
|
673
|
+
const gatewayRequestId = typeof event.requestId === "string" ? event.requestId : undefined;
|
|
658
674
|
const gatewayErrCode = event.errorCode ?? event.code;
|
|
659
675
|
// The gateway already authenticated this request before streaming,
|
|
660
676
|
// so a bare "Unauthorized" here means the account cannot use this
|
|
@@ -684,7 +700,7 @@ async function* parseJsonlStream(reader, model, captureContext = {}) {
|
|
|
684
700
|
// would otherwise do it downstream on the visitor line and
|
|
685
701
|
// record `unknown` on the credits lane alone.
|
|
686
702
|
classifyTerminalErrorCode(String(errMsg))));
|
|
687
|
-
console.error(`[builder-engine] stop reason=error model=${model} code=${errCode ?? "(none)"} error=${errMsg}`);
|
|
703
|
+
console.error(`[builder-engine] stop reason=error model=${model} code=${errCode ?? "(none)"} requestId=${gatewayRequestId ?? "(none)"} error=${errMsg}`);
|
|
688
704
|
if (isCredentialAuthError) {
|
|
689
705
|
await recordBuilderGatewayAuthFailure({
|
|
690
706
|
code: typeof gatewayErrCode === "string" ? gatewayErrCode : errCode,
|
|
@@ -699,9 +715,7 @@ async function* parseJsonlStream(reader, model, captureContext = {}) {
|
|
|
699
715
|
// it's thrown, but without these tags.
|
|
700
716
|
if (!explicitErrMsg) {
|
|
701
717
|
captureBuilderGatewayNoDetailError({
|
|
702
|
-
requestId:
|
|
703
|
-
? event.requestId
|
|
704
|
-
: undefined,
|
|
718
|
+
requestId: gatewayRequestId,
|
|
705
719
|
model,
|
|
706
720
|
gatewayUrl: captureContext.gatewayUrl,
|
|
707
721
|
rawEvent: event,
|
|
@@ -716,6 +730,10 @@ async function* parseJsonlStream(reader, model, captureContext = {}) {
|
|
|
716
730
|
...(isTransientGatewayFailure(String(errMsg))
|
|
717
731
|
? { providerRetryable: true }
|
|
718
732
|
: {}),
|
|
733
|
+
// requestId rides the stop event whether or not the gateway sent a
|
|
734
|
+
// message: a message like "...ERROR ID: <hex>" is as opaque as no
|
|
735
|
+
// message at all, and this is the only key that reaches upstream.
|
|
736
|
+
...(gatewayRequestId ? { requestId: gatewayRequestId } : {}),
|
|
719
737
|
});
|
|
720
738
|
}
|
|
721
739
|
else if (reason === "end_turn" ||
|
|
@@ -741,7 +759,7 @@ async function* parseJsonlStream(reader, model, captureContext = {}) {
|
|
|
741
759
|
yield gatewayErrorStop({
|
|
742
760
|
error: "Builder gateway stream ended without a stop event",
|
|
743
761
|
errorCode: BUILDER_GATEWAY_STREAM_ENDED_ERROR_CODE,
|
|
744
|
-
}, captureContext.creditsLane);
|
|
762
|
+
}, captureContext.creditsLane, captureContext.requestShape);
|
|
745
763
|
}
|
|
746
764
|
catch (err) {
|
|
747
765
|
const timedOut = captureContext.didGatewayTimeout?.() ?? false;
|
|
@@ -759,7 +777,7 @@ async function* parseJsonlStream(reader, model, captureContext = {}) {
|
|
|
759
777
|
: undefined,
|
|
760
778
|
});
|
|
761
779
|
}
|
|
762
|
-
yield createBuilderGatewayTimeoutStop(err, timedOut, gatewayTimeoutMs, captureContext.creditsLane);
|
|
780
|
+
yield createBuilderGatewayTimeoutStop(err, timedOut, gatewayTimeoutMs, captureContext.creditsLane, captureContext.requestShape);
|
|
763
781
|
}
|
|
764
782
|
finally {
|
|
765
783
|
// Release the reader on every exit path — early returns (invalid JSONL,
|
|
@@ -973,27 +991,27 @@ function normalizeBuilderGatewayFetchError(err, timedOut, timeoutMs) {
|
|
|
973
991
|
* the credits lane run-manager has no text left to classify at persistence time,
|
|
974
992
|
* and a run persisted as `unknown` reads as "do not attempt recovery".
|
|
975
993
|
*/
|
|
976
|
-
function createBuilderGatewayTimeoutStop(err, timedOut, timeoutMs, creditsLane) {
|
|
994
|
+
function createBuilderGatewayTimeoutStop(err, timedOut, timeoutMs, creditsLane, requestShape) {
|
|
977
995
|
const error = normalizeBuilderGatewayFetchError(err, timedOut, timeoutMs);
|
|
978
996
|
if (timedOut) {
|
|
979
997
|
// Deliberately no `providerRetryable`: the timeout spent the whole request
|
|
980
998
|
// budget, so the recovery is a fresh invocation (the client's
|
|
981
999
|
// `builder_gateway_timeout` continuation), never an in-call retry.
|
|
982
|
-
return gatewayErrorStop({ error, errorCode: "builder_gateway_timeout" }, creditsLane);
|
|
1000
|
+
return gatewayErrorStop({ error, errorCode: "builder_gateway_timeout" }, creditsLane, requestShape);
|
|
983
1001
|
}
|
|
984
1002
|
if (isBuilderGatewayNetworkError(err)) {
|
|
985
1003
|
return gatewayErrorStop({
|
|
986
1004
|
error,
|
|
987
1005
|
errorCode: BUILDER_GATEWAY_NETWORK_ERROR_CODE,
|
|
988
1006
|
providerRetryable: true,
|
|
989
|
-
}, creditsLane);
|
|
1007
|
+
}, creditsLane, requestShape);
|
|
990
1008
|
}
|
|
991
1009
|
const errorCode = classifyTerminalErrorCode(error);
|
|
992
1010
|
return gatewayErrorStop({
|
|
993
1011
|
error,
|
|
994
1012
|
...(errorCode ? { errorCode } : {}),
|
|
995
1013
|
...(isTransientGatewayFailure(error) ? { providerRetryable: true } : {}),
|
|
996
|
-
}, creditsLane);
|
|
1014
|
+
}, creditsLane, requestShape);
|
|
997
1015
|
}
|
|
998
1016
|
function formatTimeoutMs(timeoutMs) {
|
|
999
1017
|
if (timeoutMs < 1000)
|