@agent-native/core 0.134.0 → 0.134.1

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 (57) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +6 -0
  3. package/corpus/core/docs/content/template-content-developers.mdx +1 -1
  4. package/corpus/core/package.json +1 -1
  5. package/corpus/templates/clips/changelog/2026-07-29-meeting-notes-no-longer-transcribe-the-other-side-twice-when.md +6 -0
  6. package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +37 -48
  7. package/corpus/templates/clips/desktop/src/lib/transcription-capture.ts +7 -5
  8. package/corpus/templates/clips/desktop/src/lib/transcription-engine.ts +182 -106
  9. package/corpus/templates/clips/desktop/src/overlays/live-transcript.tsx +43 -14
  10. package/corpus/templates/clips/desktop/src/overlays/recording-pill.tsx +3 -3
  11. package/corpus/templates/clips/desktop/src-tauri/src/echo_guard.rs +443 -0
  12. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +1 -0
  13. package/corpus/templates/clips/desktop/src-tauri/src/whisper_speech.rs +125 -16
  14. package/corpus/templates/content/.agents/skills/document-editing/references/databases.md +6 -4
  15. package/corpus/templates/content/actions/_database-membership-lock.ts +25 -0
  16. package/corpus/templates/content/actions/_database-row-batch.ts +6 -2
  17. package/corpus/templates/content/actions/_database-source-utils.ts +289 -129
  18. package/corpus/templates/content/actions/_database-utils.ts +12 -3
  19. package/corpus/templates/content/actions/add-content-database-source-field-property.ts +39 -21
  20. package/corpus/templates/content/actions/attach-content-database-source.ts +4 -1
  21. package/corpus/templates/content/actions/change-content-database-source-role.ts +3 -5
  22. package/corpus/templates/content/actions/duplicate-document-property.ts +59 -46
  23. package/corpus/templates/content/actions/execute-builder-source-execution.ts +77 -71
  24. package/corpus/templates/content/actions/materialize-builder-required-fields.ts +7 -0
  25. package/corpus/templates/content/actions/remove-database-items.ts +183 -0
  26. package/corpus/templates/content/actions/set-document-property.ts +57 -39
  27. package/corpus/templates/content/actions/sync-local-folder-source.ts +7 -0
  28. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +471 -329
  29. package/corpus/templates/content/app/components/editor/database/row-access.ts +45 -0
  30. package/corpus/templates/content/app/components/editor/database/shared.tsx +141 -38
  31. package/corpus/templates/content/app/hooks/use-content-database.ts +2 -2
  32. package/corpus/templates/content/app/i18n-data.ts +130 -0
  33. package/corpus/templates/content/changelog/2026-07-30-database-rows-can-now-be-removed-without-deleting-their-page.md +6 -0
  34. package/corpus/templates/content/parity/eval-scenarios.ts +2 -2
  35. package/corpus/templates/content/parity/matrix.md +1 -1
  36. package/corpus/templates/content/parity/matrix.ts +4 -5
  37. package/corpus/templates/content/shared/api.ts +4 -0
  38. package/corpus/templates/design/.generated/bridge/editor-chrome.generated.ts +26 -11
  39. package/corpus/templates/design/actions/take-design-screenshot.ts +7 -0
  40. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +11 -3
  41. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +4 -1
  42. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +54 -17
  43. package/corpus/templates/design/app/components/design/design-canvas/local-runtime.ts +126 -0
  44. package/corpus/templates/design/app/components/templates/TemplatePreview.tsx +2 -0
  45. package/corpus/templates/design/app/pages/Index.tsx +3 -1
  46. package/corpus/templates/design/app/pages/Present.tsx +2 -1
  47. package/corpus/templates/design/changelog/2026-07-30-a-broken-inline-script-in-a-generated-screen-is-now-caught-o.md +6 -0
  48. package/corpus/templates/design/changelog/2026-07-30-design-html-is-now-validated-with-a-spec-grade-html-parser-i.md +6 -0
  49. package/corpus/templates/design/changelog/2026-07-30-designs-now-load-their-tailwind-and-alpine-runtimes-from-the.md +6 -0
  50. package/corpus/templates/design/changelog/2026-07-30-designs-with-a-broken-alpine-expression-are-now-caught-on-sa.md +6 -0
  51. package/corpus/templates/design/changelog/2026-07-30-editing-a-design-no-longer-strips-its-styling-the-canvas-kep.md +6 -0
  52. package/corpus/templates/design/package.json +4 -0
  53. package/corpus/templates/design/shared/html-integrity.ts +802 -556
  54. package/dist/notifications/routes.d.ts +3 -3
  55. package/docs/content/template-content-developers.mdx +1 -1
  56. package/package.json +1 -1
  57. package/corpus/templates/content/actions/delete-database-items.ts +0 -89
package/corpus/README.md CHANGED
@@ -30,4 +30,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
30
30
 
31
31
  - core files: 1652
32
32
  - toolkit files: 168
33
- - template files: 7421
33
+ - template files: 7432
@@ -1,5 +1,11 @@
1
1
  # @agent-native/core
2
2
 
3
+ ## 0.134.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 6c165cd: Document the permission-aware Content database membership removal action.
8
+
3
9
  ## 0.134.0
4
10
 
5
11
  ### Minor Changes
@@ -278,7 +278,7 @@ Every operation is a TypeScript file in `templates/content/actions/`, auto-mount
278
278
 
279
279
  **Comments** — `list-comments`, `add-comment`, `update-comment`, `delete-comment`.
280
280
 
281
- **Databases, properties & views** — `create-content-database`, `create-inline-content-database`, `get-content-database`, `list-content-databases`, `delete-content-database`, `restore-content-database`, `list-trashed-content-databases`, `add-database-item`, `duplicate-database-item`, `duplicate-database-items`, `delete-database-items`, `move-database-item`, `update-content-database-view`, `get-content-database-personal-view`, `update-content-database-personal-view`, `list-document-properties`, `configure-document-property`, `set-document-property`, `duplicate-document-property`, `delete-document-property`, `reorder-document-property`, `submit-content-database-form`.
281
+ **Databases, properties & views** — `create-content-database`, `create-inline-content-database`, `get-content-database`, `list-content-databases`, `delete-content-database`, `restore-content-database`, `list-trashed-content-databases`, `add-database-item`, `duplicate-database-item`, `duplicate-database-items`, `remove-database-items`, `move-database-item`, `update-content-database-view`, `get-content-database-personal-view`, `update-content-database-personal-view`, `list-document-properties`, `configure-document-property`, `set-document-property`, `duplicate-document-property`, `delete-document-property`, `reorder-document-property`, `submit-content-database-form`.
282
282
 
283
283
  **External database sources** — `attach-content-database-source`, `disconnect-content-database-source`, `get-content-database-source`, `refresh-content-database-source`, `review-content-database-source-change-set`, `bind-content-database-source-field`, `add-content-database-source-field-property`, `suggest-source-join-key`, `list-notion-database-sources`, `list-builder-cms-models`. Builder's write-back path adds `prepare-builder-source-review`, `preview-builder-source-review`, `prepare-builder-source-execution`, `validate-builder-source-execution`, `execute-builder-source-execution`, `execute-builder-source-batch`, `cancel-prepared-builder-source-update`, `stage-builder-revision`, `stage-builder-source-bulk-update`, `materialize-builder-required-fields`, and `process-builder-body-hydration`.
284
284
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.134.0",
3
+ "version": "0.134.1",
4
4
  "description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
5
5
  "homepage": "https://github.com/BuilderIO/agent-native#readme",
6
6
  "bugs": {
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-07-29
4
+ ---
5
+
6
+ Meeting notes no longer transcribe the other side twice when you are on speakers instead of headphones
@@ -8,11 +8,14 @@ import {
8
8
  appendFinalTranscript,
9
9
  onFinalTranscript,
10
10
  restartTranscriptionEngine,
11
- speakerFor,
12
11
  startTranscriptionEngine,
13
12
  stopTranscriptionEngine,
13
+ transcriptFullText,
14
+ transcriptLineFromSegment,
15
+ transcriptSegments,
14
16
  type SourcedTranscriptSegment,
15
17
  type TranscriptionEngine,
18
+ type TranscriptLine,
16
19
  } from "../lib/transcription-engine";
17
20
  import { normalizeServerUrl } from "../lib/url";
18
21
 
@@ -31,8 +34,7 @@ export interface MeetingTranscriptionPayload {
31
34
  interface MeetingTranscriptionSession {
32
35
  meetingId: string;
33
36
  recordingId: string;
34
- lines: string[];
35
- segments: SourcedTranscriptSegment[];
37
+ lines: TranscriptLine[];
36
38
  unlisten: Array<() => void>;
37
39
  flushTimer: ReturnType<typeof setTimeout> | null;
38
40
  stopping: boolean;
@@ -52,6 +54,22 @@ interface MeetingTranscriptionSession {
52
54
  dirtySeq: number;
53
55
  }
54
56
 
57
+ /** What the pill overlay needs to render a line: text, side, and timestamp.
58
+ * The verbatim segments stay behind in the session. */
59
+ interface PillTranscriptLine {
60
+ text: string;
61
+ source: "mic" | "system";
62
+ startMs?: number;
63
+ }
64
+
65
+ function pillTranscriptLines(lines: TranscriptLine[]): PillTranscriptLine[] {
66
+ return lines.map((line) => ({
67
+ text: line.text,
68
+ source: line.source,
69
+ startMs: line.startMs ?? undefined,
70
+ }));
71
+ }
72
+
55
73
  type CallClipsAction = <T>(
56
74
  name: string,
57
75
  body: Record<string, unknown>,
@@ -79,11 +97,7 @@ export function useMeetingTranscription({
79
97
  const pendingPillInitRef = useRef<{
80
98
  meetingId: string;
81
99
  initialNotes: string;
82
- preloadedLines?: Array<{
83
- text: string;
84
- source: "mic" | "system";
85
- startMs?: number;
86
- }>;
100
+ preloadedLines?: PillTranscriptLine[];
87
101
  } | null>(null);
88
102
 
89
103
  const normalizedServerUrl = useMemo(
@@ -119,8 +133,8 @@ export function useMeetingTranscription({
119
133
  const run = (async () => {
120
134
  await callClipsAction("save-browser-transcript", {
121
135
  recordingId: session.recordingId,
122
- fullText: session.lines.join("\n\n"),
123
- segments: session.segments,
136
+ fullText: transcriptFullText(session.lines),
137
+ segments: transcriptSegments(session.lines),
124
138
  source: session.engine,
125
139
  overwriteReady: true,
126
140
  });
@@ -314,7 +328,6 @@ export function useMeetingTranscription({
314
328
  meetingId: resolvedMeetingId,
315
329
  recordingId,
316
330
  lines: [],
317
- segments: [],
318
331
  unlisten: [],
319
332
  flushTimer: null,
320
333
  stopping: false,
@@ -363,13 +376,7 @@ export function useMeetingTranscription({
363
376
  })),
364
377
  }
365
378
  : event;
366
- if (
367
- appendFinalTranscript(
368
- timelineEvent,
369
- session.lines,
370
- session.segments,
371
- )
372
- ) {
379
+ if (appendFinalTranscript(timelineEvent, session.lines)) {
373
380
  scheduleFlush();
374
381
  }
375
382
  }),
@@ -539,11 +546,7 @@ export function useMeetingTranscription({
539
546
  pendingPillInitRef.current = {
540
547
  meetingId: resolvedMeetingId,
541
548
  initialNotes,
542
- preloadedLines: session.segments.map((segment) => ({
543
- text: segment.text,
544
- source: segment.source,
545
- startMs: segment.startMs,
546
- })),
549
+ preloadedLines: pillTranscriptLines(session.lines),
547
550
  };
548
551
  emit("clips:meeting-notes-init", {
549
552
  meetingId: resolvedMeetingId,
@@ -561,25 +564,16 @@ export function useMeetingTranscription({
561
564
  source?: "mic" | "system";
562
565
  }>;
563
566
  if (segs.length > 0) {
564
- const preloadedLineStrings = segs.map(
565
- (s) => `${speakerFor(s.source)}: ${s.text}`,
567
+ const storedLines = segs.map((s) =>
568
+ transcriptLineFromSegment({
569
+ startMs: s.startMs ?? 0,
570
+ endMs: s.endMs ?? 0,
571
+ text: s.text,
572
+ source: s.source ?? "mic",
573
+ }),
566
574
  );
567
- const preloadedSegments = segs.map((s) => ({
568
- startMs: s.startMs ?? 0,
569
- endMs: s.endMs ?? 0,
570
- text: s.text,
571
- source: s.source ?? ("mic" as const),
572
- }));
573
- session.lines = [...preloadedLineStrings, ...session.lines];
574
- session.segments = [
575
- ...preloadedSegments,
576
- ...session.segments,
577
- ];
578
- const preloadedLines = session.segments.map((s) => ({
579
- text: s.text,
580
- source: s.source,
581
- startMs: s.startMs,
582
- }));
575
+ session.lines = [...storedLines, ...session.lines];
576
+ const preloadedLines = pillTranscriptLines(session.lines);
583
577
  // Store in ref so clips:pill-ready can re-emit if the
584
578
  // pill window mounts after this fetch resolves.
585
579
  if (
@@ -648,15 +642,10 @@ export function useMeetingTranscription({
648
642
  .then((history) => {
649
643
  if (sessionRef.current !== session) return;
650
644
  const historyLines = history.segments.map(
651
- (segment) => `${speakerFor(segment.source)}: ${segment.text}`,
645
+ transcriptLineFromSegment,
652
646
  );
653
647
  session.lines = [...historyLines, ...session.lines];
654
- session.segments = [...history.segments, ...session.segments];
655
- const preloadedLines = session.segments.map((segment) => ({
656
- text: segment.text,
657
- source: segment.source,
658
- startMs: segment.startMs,
659
- }));
648
+ const preloadedLines = pillTranscriptLines(session.lines);
660
649
  if (pendingPillInitRef.current?.meetingId === resolvedMeetingId) {
661
650
  pendingPillInitRef.current = {
662
651
  ...pendingPillInitRef.current,
@@ -19,7 +19,10 @@ import {
19
19
  startTranscriptionEngine,
20
20
  stopTranscriptionEngine,
21
21
  TranscriptionEngine,
22
+ transcriptFullText,
23
+ transcriptSegments,
22
24
  type SourcedTranscriptSegment,
25
+ type TranscriptLine,
23
26
  } from "./transcription-engine";
24
27
 
25
28
  /** Grace period after stop for whisper to emit any flushed trailing finals. */
@@ -307,8 +310,7 @@ export async function startTranscriptionCapture(
307
310
  voiceProcessing?: boolean;
308
311
  },
309
312
  ): Promise<TranscriptionCapture | null> {
310
- const lines: string[] = [];
311
- const segments: SourcedTranscriptSegment[] = [];
313
+ const lines: TranscriptLine[] = [];
312
314
  let disposed = false;
313
315
  let paused = false;
314
316
  let desiredPaused = false;
@@ -331,8 +333,8 @@ export async function startTranscriptionCapture(
331
333
  };
332
334
 
333
335
  const captured = (): CapturedTranscript => ({
334
- text: lines.join("\n\n").trim(),
335
- segments,
336
+ text: transcriptFullText(lines),
337
+ segments: transcriptSegments(lines),
336
338
  });
337
339
 
338
340
  let engine: TranscriptionEngine;
@@ -340,7 +342,7 @@ export async function startTranscriptionCapture(
340
342
  unlistens.push(
341
343
  await onFinalTranscript((event) => {
342
344
  if (disposed) return;
343
- appendFinalTranscript(event, lines, segments);
345
+ appendFinalTranscript(event, lines);
344
346
  }),
345
347
  );
346
348
 
@@ -30,9 +30,6 @@ export interface SourcedTranscriptSegment extends TranscriptSegment {
30
30
  source: TranscriptSource;
31
31
  }
32
32
 
33
- const DUPLICATE_TOKEN_OVERLAP = 0.72;
34
- const DUPLICATE_TIME_OVERLAP = 0.35;
35
-
36
33
  export interface FinalTranscriptEvent {
37
34
  /** Raw text (not trimmed); callers decide whether to skip empties. */
38
35
  text: string;
@@ -86,133 +83,212 @@ function transcriptWords(text: string): string[] {
86
83
  return normalized ? normalized.split(/\s+/) : [];
87
84
  }
88
85
 
89
- function tokenOverlap(left: string[], right: string[]): number {
90
- if (left.length === 0 || right.length === 0) return 0;
91
-
92
- const rightWords = new Set(right);
93
- const sharedWords = new Set(left.filter((word) => rightWords.has(word)));
94
- return sharedWords.size / Math.min(new Set(left).size, new Set(right).size);
95
- }
96
-
97
- function timeOverlapRatio(
98
- left: SourcedTranscriptSegment,
99
- right: SourcedTranscriptSegment,
100
- ): number {
101
- const overlapStart = Math.max(left.startMs, right.startMs);
102
- const overlapEnd = Math.min(left.endMs, right.endMs);
103
- const overlapMs = Math.max(0, overlapEnd - overlapStart);
104
- const shorterDuration = Math.max(
105
- 1,
106
- Math.min(left.endMs - left.startMs, right.endMs - right.startMs),
107
- );
108
-
109
- return overlapMs / shorterDuration;
86
+ // ---------------------------------------------------------------------------
87
+ // Echo de-duplication
88
+ // ---------------------------------------------------------------------------
89
+ //
90
+ // Without headphones the microphone re-records whatever the speakers play, so
91
+ // the remote side can reach the transcript twice: cleanly on the system stream
92
+ // and, whenever the acoustic guard in `echo_guard.rs` was not confident enough
93
+ // to drop it, again on the mic. The leak only ever runs one way, because a call
94
+ // app never plays the local user back. So when both streams carry the same
95
+ // words at the same time, the system copy is the real one and the mic copy is
96
+ // echo — no matter which stream happened to finalize first.
97
+
98
+ /** Share of the *longer* of the two word lists that has to match. Scoring the
99
+ * longer side is what keeps a brief interjection ("yeah, I think so") alive
100
+ * next to a long remote passage that happens to contain those words in order:
101
+ * echo repeats a whole utterance, it does not sprinkle a few words into one. */
102
+ const ECHO_MATCH_RATIO = 0.65;
103
+ /** Fewer matched words than this is coincidence, not evidence. */
104
+ const ECHO_MIN_WORDS = 4;
105
+ /** Words that have to agree before a long in-order run counts as echo on its
106
+ * own. Two people do not independently say eight of the same words, in the
107
+ * same order, at the same moment. */
108
+ const ECHO_LONG_MATCH_WORDS = 8;
109
+ /** How many recently appended lines count as "at the same time".
110
+ *
111
+ * Arrival order, not timestamps: each stream's Whisper timestamps are
112
+ * estimates against its own rolling buffer, and the two streams cut those
113
+ * buffers at their own silences, so the same words routinely carry stamps
114
+ * seconds apart. Both finals still *arrive* within a second or two of each
115
+ * other, because they are transcribed from the same sound. Finalized lines also
116
+ * get a loose timestamp bound below to reject much later deliberate repeats. */
117
+ const ECHO_RECENT_LINES = 6;
118
+ /** Cross-stream timestamps are approximate, but echo finals should still begin
119
+ * within this loose bound of each other. */
120
+ const ECHO_MAX_START_DELTA_MS = 15_000;
121
+
122
+ /** Length of the longest common subsequence of two word lists. Subsequence
123
+ * rather than set intersection because echo repeats the words *in order*,
124
+ * while two people using the same vocabulary do not — and rather than exact
125
+ * equality because Whisper mangles echo with dropped and substituted words. */
126
+ function commonWordRun(left: string[], right: string[]): number {
127
+ let previous = new Array<number>(right.length + 1).fill(0);
128
+ let current = new Array<number>(right.length + 1).fill(0);
129
+ for (const word of left) {
130
+ for (let index = 0; index < right.length; index++) {
131
+ current[index + 1] =
132
+ word === right[index]
133
+ ? previous[index] + 1
134
+ : Math.max(current[index], previous[index + 1]);
135
+ }
136
+ [previous, current] = [current, previous];
137
+ }
138
+ return previous[right.length];
110
139
  }
111
140
 
112
- function isDuplicateTranscriptSegment(
113
- existing: SourcedTranscriptSegment,
114
- incoming: SourcedTranscriptSegment,
141
+ /** Whether `text` heard on the mic is the speakers bleeding back into it.
142
+ *
143
+ * Exported because the live overlay has to make the same call on in-flight
144
+ * partials, which never reach `appendFinalTranscript`.
145
+ *
146
+ * Scored against every contiguous run of the recent system lines, not against
147
+ * all of them glued together: the two streams cut speech at different points,
148
+ * so one mic line can echo a single system line or straddle two, and gluing an
149
+ * unrelated third one in would bury the match. */
150
+ export function isMicEcho(
151
+ text: string,
152
+ lines: TranscriptLine[],
153
+ startMs: number | null = null,
115
154
  ): boolean {
116
- if (existing.source === incoming.source) return false;
117
-
118
- const existingText = normalizedTranscriptText(existing.text);
119
- const incomingText = normalizedTranscriptText(incoming.text);
120
- const existingWords = transcriptWords(existing.text);
121
- const incomingWords = transcriptWords(incoming.text);
122
-
123
- if (
124
- !existingText ||
125
- !incomingText ||
126
- existingWords.length < 3 ||
127
- incomingWords.length < 3
128
- ) {
129
- return false;
155
+ const words = transcriptWords(text);
156
+ if (words.length < ECHO_MIN_WORDS) return false;
157
+
158
+ const nearby = lines
159
+ .slice(-ECHO_RECENT_LINES)
160
+ .filter(
161
+ (line) =>
162
+ line.source === "system" &&
163
+ !line.historical &&
164
+ (startMs === null ||
165
+ line.startMs === null ||
166
+ Math.abs(startMs - line.startMs) <= ECHO_MAX_START_DELTA_MS),
167
+ )
168
+ .map((line) => transcriptWords(line.text));
169
+
170
+ for (let start = 0; start < nearby.length; start++) {
171
+ const run: string[] = [];
172
+ for (let end = start; end < nearby.length; end++) {
173
+ run.push(...nearby[end]);
174
+ const matched = commonWordRun(words, run);
175
+ if (matched < ECHO_MIN_WORDS) continue;
176
+ // Either the two are the same length and mostly agree, or they agree on
177
+ // a stretch long enough that nothing but echo explains it.
178
+ const sameUtterance =
179
+ matched / Math.max(words.length, run.length) >= ECHO_MATCH_RATIO;
180
+ const longRun =
181
+ matched >= ECHO_LONG_MATCH_WORDS &&
182
+ matched / words.length >= ECHO_MATCH_RATIO;
183
+ if (sameUtterance || longRun) return true;
184
+ }
130
185
  }
186
+ return false;
187
+ }
131
188
 
132
- if (timeOverlapRatio(existing, incoming) < DUPLICATE_TIME_OVERLAP) {
133
- return false;
189
+ /** Drop the recent mic lines that a just-appended system line exposes as echo. */
190
+ function retractMicEcho(lines: TranscriptLine[]): void {
191
+ const snapshot = [...lines];
192
+ const oldest = Math.max(0, snapshot.length - ECHO_RECENT_LINES);
193
+ const removals: number[] = [];
194
+ for (let index = snapshot.length - 1; index >= oldest; index--) {
195
+ const line = snapshot[index];
196
+ if (line.source !== "mic" || line.historical) continue;
197
+ const evidence = snapshot.slice(index, index + ECHO_RECENT_LINES);
198
+ if (isMicEcho(line.text, evidence, line.startMs)) removals.push(index);
134
199
  }
200
+ for (const index of removals) lines.splice(index, 1);
201
+ }
135
202
 
136
- return (
137
- existingText === incomingText ||
138
- tokenOverlap(existingWords, incomingWords) >= DUPLICATE_TOKEN_OVERLAP
139
- );
203
+ // ---------------------------------------------------------------------------
204
+ // Transcript lines
205
+ // ---------------------------------------------------------------------------
206
+
207
+ /** One speaker-labelled transcript line. `segments` carries the verbatim
208
+ * Whisper timings behind the line and is empty for the mic-only fallback
209
+ * engines, which report no timestamps. */
210
+ export interface TranscriptLine {
211
+ source: TranscriptSource;
212
+ /** Preloaded lines are display/persistence data, not live echo evidence. */
213
+ historical?: boolean;
214
+ /** Meeting-timeline position, or null from an engine that reports none.
215
+ * Not 0 — the overlay renders a timestamp for every line that has one, and
216
+ * "start of the meeting" is a different claim from "unknown". */
217
+ startMs: number | null;
218
+ text: string;
219
+ segments: SourcedTranscriptSegment[];
140
220
  }
141
221
 
142
- function isDuplicateTranscriptLine(
143
- lines: string[],
144
- source: TranscriptSource,
145
- text: string,
146
- ): boolean {
147
- const words = transcriptWords(text);
148
- if (words.length < 4) return false;
222
+ function lineFromSegments(
223
+ segments: SourcedTranscriptSegment[],
224
+ ): TranscriptLine {
225
+ return {
226
+ source: segments[0].source,
227
+ startMs: segments[0].startMs,
228
+ text: segments.map((segment) => segment.text).join(" "),
229
+ segments,
230
+ };
231
+ }
149
232
 
150
- const speaker = speakerFor(source);
151
- return lines.some((line) => {
152
- const separatorIndex = line.indexOf(":");
153
- if (separatorIndex === -1 || line.slice(0, separatorIndex) === speaker) {
154
- return false;
155
- }
233
+ /** Rebuild a line from a stored segment, for preloaded transcript history. */
234
+ export function transcriptLineFromSegment(
235
+ segment: SourcedTranscriptSegment,
236
+ ): TranscriptLine {
237
+ return { ...lineFromSegments([segment]), historical: true };
238
+ }
156
239
 
157
- const existingText = line.slice(separatorIndex + 1);
158
- const existingWords = transcriptWords(existingText);
159
- return (
160
- normalizedTranscriptText(existingText) ===
161
- normalizedTranscriptText(text) ||
162
- tokenOverlap(existingWords, words) >= DUPLICATE_TOKEN_OVERLAP
163
- );
164
- });
240
+ /** Speaker-labelled text, as persisted by `save-browser-transcript`. */
241
+ export function transcriptFullText(lines: TranscriptLine[]): string {
242
+ return lines
243
+ .map((line) => `${speakerFor(line.source)}: ${line.text}`)
244
+ .join("\n\n")
245
+ .trim();
246
+ }
247
+
248
+ /** Flattened verbatim segments, as persisted alongside the text. */
249
+ export function transcriptSegments(
250
+ lines: TranscriptLine[],
251
+ ): SourcedTranscriptSegment[] {
252
+ return lines.flatMap((line) => line.segments);
165
253
  }
166
254
 
167
255
  /**
168
- * Fold a final-transcript event into a running transcript: appends a
169
- * speaker-labelled line and the event's (non-empty) segments tagged with the
170
- * event source. Mutates `lines`/`segments` in place. Returns true if anything
171
- * was appended (i.e. the event had non-empty text).
256
+ * Fold a final-transcript event into a running transcript, dropping mic speech
257
+ * that only echoes the system audio and retracting mic lines that a later
258
+ * system line exposes as echo. Mutates `lines` in place; returns whether the
259
+ * transcript changed.
172
260
  */
173
261
  export function appendFinalTranscript(
174
262
  event: FinalTranscriptEvent,
175
- lines: string[],
176
- segments: SourcedTranscriptSegment[],
263
+ lines: TranscriptLine[],
177
264
  ): boolean {
178
265
  const text = event.text.trim();
179
266
  if (!text) return false;
180
267
 
181
- if (event.segments.length === 0) {
182
- if (isDuplicateTranscriptLine(lines, event.source, text)) return false;
183
-
184
- lines.push(`${speakerFor(event.source)}: ${text}`);
268
+ const segments: SourcedTranscriptSegment[] = event.segments
269
+ .map((segment) => ({
270
+ startMs: segment.startMs,
271
+ endMs: segment.endMs,
272
+ text: segment.text?.trim() ?? "",
273
+ source: event.source,
274
+ }))
275
+ .filter((segment) => segment.text.length > 0);
276
+
277
+ // One event is one stream's take on one utterance, so the whole line is the
278
+ // unit that is or is not echo. Engines without timestamps still produce a
279
+ // line, just one carrying no segments behind it.
280
+ const line: TranscriptLine = segments.length
281
+ ? lineFromSegments(segments)
282
+ : { source: event.source, startMs: null, text, segments: [] };
283
+
284
+ if (line.source === "mic") {
285
+ if (isMicEcho(line.text, lines, line.startMs)) return false;
286
+ lines.push(line);
185
287
  return true;
186
288
  }
187
289
 
188
- const uniqueSegments = event.segments.filter((segment) => {
189
- const segText = segment.text?.trim();
190
- if (!segText) return false;
191
-
192
- return !segments.some((existing) =>
193
- isDuplicateTranscriptSegment(existing, {
194
- ...segment,
195
- text: segText,
196
- source: event.source,
197
- }),
198
- );
199
- });
200
-
201
- if (uniqueSegments.length === 0) return false;
202
-
203
- lines.push(
204
- `${speakerFor(event.source)}: ${uniqueSegments
205
- .map((segment) => segment.text.trim())
206
- .join(" ")}`,
207
- );
208
- for (const seg of uniqueSegments) {
209
- segments.push({
210
- startMs: seg.startMs,
211
- endMs: seg.endMs,
212
- text: seg.text.trim(),
213
- source: event.source,
214
- });
215
- }
290
+ lines.push(line);
291
+ retractMicEcho(lines);
216
292
  return true;
217
293
  }
218
294