@agent-native/core 0.133.3 → 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 (221) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +62 -0
  3. package/corpus/core/docs/content/observability.mdx +41 -1
  4. package/corpus/core/docs/content/template-content-developers.mdx +1 -1
  5. package/corpus/core/docs/content/tracking.mdx +25 -0
  6. package/corpus/core/package.json +1 -1
  7. package/corpus/core/src/agent/run-manager.ts +7 -0
  8. package/corpus/core/src/client/agent-chat-adapter.ts +10 -0
  9. package/corpus/core/src/client/analytics.ts +105 -1
  10. package/corpus/core/src/deploy/build.ts +38 -1
  11. package/corpus/core/src/observability/posthog-ai.ts +294 -0
  12. package/corpus/core/src/observability/routes.ts +29 -6
  13. package/corpus/core/src/observability/traces.ts +287 -12
  14. package/corpus/core/src/observability/types.ts +11 -0
  15. package/corpus/core/src/secrets/register-framework-secrets.ts +16 -0
  16. package/corpus/core/src/server/agent-run-context.ts +21 -0
  17. package/corpus/core/src/server/capture-error.ts +8 -0
  18. package/corpus/core/src/server/core-routes-plugin.ts +75 -4
  19. package/corpus/core/src/server/deploy-environment.ts +57 -0
  20. package/corpus/core/src/server/error-noise-filter.ts +268 -0
  21. package/corpus/core/src/server/posthog-config.ts +75 -0
  22. package/corpus/core/src/server/request-context.ts +6 -0
  23. package/corpus/core/src/server/sentry-config.ts +4 -8
  24. package/corpus/core/src/server/sentry-plugin.ts +13 -29
  25. package/corpus/core/src/server/sentry.ts +13 -156
  26. package/corpus/core/src/server/ssr-handler.ts +6 -1
  27. package/corpus/core/src/shared/mcp-embed-headers.ts +1 -1
  28. package/corpus/core/src/templates/workspace-core/.agents/skills/observability/SKILL.md +36 -10
  29. package/corpus/core/src/templates/workspace-core/.agents/skills/tracking/SKILL.md +55 -1
  30. package/corpus/core/src/tracking/error-capture.ts +44 -109
  31. package/corpus/core/src/tracking/index.ts +11 -0
  32. package/corpus/core/src/tracking/posthog-exception.ts +305 -0
  33. package/corpus/core/src/tracking/providers.ts +91 -14
  34. package/corpus/core/src/tracking/redaction.ts +105 -0
  35. package/corpus/templates/clips/changelog/2026-07-29-meeting-notes-no-longer-transcribe-the-other-side-twice-when.md +6 -0
  36. package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +37 -48
  37. package/corpus/templates/clips/desktop/src/lib/transcription-capture.ts +7 -5
  38. package/corpus/templates/clips/desktop/src/lib/transcription-engine.ts +182 -106
  39. package/corpus/templates/clips/desktop/src/overlays/live-transcript.tsx +43 -14
  40. package/corpus/templates/clips/desktop/src/overlays/recording-pill.tsx +3 -3
  41. package/corpus/templates/clips/desktop/src-tauri/src/echo_guard.rs +443 -0
  42. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +1 -0
  43. package/corpus/templates/clips/desktop/src-tauri/src/whisper_speech.rs +125 -16
  44. package/corpus/templates/content/.agents/skills/document-editing/references/databases.md +6 -4
  45. package/corpus/templates/content/actions/_database-membership-lock.ts +25 -0
  46. package/corpus/templates/content/actions/_database-row-batch.ts +6 -2
  47. package/corpus/templates/content/actions/_database-source-utils.ts +289 -129
  48. package/corpus/templates/content/actions/_database-utils.ts +12 -3
  49. package/corpus/templates/content/actions/add-content-database-source-field-property.ts +39 -21
  50. package/corpus/templates/content/actions/attach-content-database-source.ts +4 -1
  51. package/corpus/templates/content/actions/change-content-database-source-role.ts +3 -5
  52. package/corpus/templates/content/actions/duplicate-document-property.ts +59 -46
  53. package/corpus/templates/content/actions/execute-builder-source-execution.ts +77 -71
  54. package/corpus/templates/content/actions/materialize-builder-required-fields.ts +7 -0
  55. package/corpus/templates/content/actions/remove-database-items.ts +183 -0
  56. package/corpus/templates/content/actions/set-document-property.ts +57 -39
  57. package/corpus/templates/content/actions/sync-local-folder-source.ts +7 -0
  58. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +471 -329
  59. package/corpus/templates/content/app/components/editor/database/row-access.ts +45 -0
  60. package/corpus/templates/content/app/components/editor/database/shared.tsx +141 -38
  61. package/corpus/templates/content/app/hooks/use-content-database.ts +2 -2
  62. package/corpus/templates/content/app/i18n-data.ts +130 -0
  63. package/corpus/templates/content/changelog/2026-07-30-database-rows-can-now-be-removed-without-deleting-their-page.md +6 -0
  64. package/corpus/templates/content/parity/eval-scenarios.ts +2 -2
  65. package/corpus/templates/content/parity/matrix.md +1 -1
  66. package/corpus/templates/content/parity/matrix.ts +4 -5
  67. package/corpus/templates/content/shared/api.ts +4 -0
  68. package/corpus/templates/design/.generated/bridge/editor-chrome.generated.ts +26 -11
  69. package/corpus/templates/design/actions/take-design-screenshot.ts +7 -0
  70. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +11 -3
  71. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +4 -1
  72. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +54 -17
  73. package/corpus/templates/design/app/components/design/design-canvas/local-runtime.ts +126 -0
  74. package/corpus/templates/design/app/components/templates/TemplatePreview.tsx +2 -0
  75. package/corpus/templates/design/app/pages/Index.tsx +3 -1
  76. package/corpus/templates/design/app/pages/Present.tsx +2 -1
  77. package/corpus/templates/design/changelog/2026-07-30-a-broken-inline-script-in-a-generated-screen-is-now-caught-o.md +6 -0
  78. package/corpus/templates/design/changelog/2026-07-30-design-html-is-now-validated-with-a-spec-grade-html-parser-i.md +6 -0
  79. package/corpus/templates/design/changelog/2026-07-30-designs-now-load-their-tailwind-and-alpine-runtimes-from-the.md +6 -0
  80. package/corpus/templates/design/changelog/2026-07-30-designs-with-a-broken-alpine-expression-are-now-caught-on-sa.md +6 -0
  81. package/corpus/templates/design/changelog/2026-07-30-editing-a-design-no-longer-strips-its-styling-the-canvas-kep.md +6 -0
  82. package/corpus/templates/design/package.json +4 -0
  83. package/corpus/templates/design/shared/html-integrity.ts +802 -556
  84. package/dist/agent/run-manager.d.ts.map +1 -1
  85. package/dist/agent/run-manager.js +7 -0
  86. package/dist/agent/run-manager.js.map +1 -1
  87. package/dist/client/agent-chat-adapter.d.ts.map +1 -1
  88. package/dist/client/agent-chat-adapter.js +12 -0
  89. package/dist/client/agent-chat-adapter.js.map +1 -1
  90. package/dist/client/analytics.d.ts +8 -0
  91. package/dist/client/analytics.d.ts.map +1 -1
  92. package/dist/client/analytics.js +95 -1
  93. package/dist/client/analytics.js.map +1 -1
  94. package/dist/collab/routes.d.ts +1 -1
  95. package/dist/collab/struct-routes.d.ts +1 -1
  96. package/dist/deploy/build.d.ts.map +1 -1
  97. package/dist/deploy/build.js +38 -1
  98. package/dist/deploy/build.js.map +1 -1
  99. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  100. package/dist/notifications/routes.d.ts +1 -1
  101. package/dist/observability/posthog-ai.d.ts +126 -0
  102. package/dist/observability/posthog-ai.d.ts.map +1 -0
  103. package/dist/observability/posthog-ai.js +190 -0
  104. package/dist/observability/posthog-ai.js.map +1 -0
  105. package/dist/observability/routes.d.ts +3 -3
  106. package/dist/observability/routes.d.ts.map +1 -1
  107. package/dist/observability/routes.js +26 -6
  108. package/dist/observability/routes.js.map +1 -1
  109. package/dist/observability/traces.d.ts +9 -0
  110. package/dist/observability/traces.d.ts.map +1 -1
  111. package/dist/observability/traces.js +219 -10
  112. package/dist/observability/traces.js.map +1 -1
  113. package/dist/observability/types.d.ts +10 -0
  114. package/dist/observability/types.d.ts.map +1 -1
  115. package/dist/observability/types.js +1 -0
  116. package/dist/observability/types.js.map +1 -1
  117. package/dist/progress/routes.d.ts +1 -1
  118. package/dist/resources/handlers.d.ts +1 -1
  119. package/dist/secrets/register-framework-secrets.d.ts.map +1 -1
  120. package/dist/secrets/register-framework-secrets.js +14 -0
  121. package/dist/secrets/register-framework-secrets.js.map +1 -1
  122. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  123. package/dist/server/agent-run-context.d.ts +8 -0
  124. package/dist/server/agent-run-context.d.ts.map +1 -1
  125. package/dist/server/agent-run-context.js +18 -0
  126. package/dist/server/agent-run-context.js.map +1 -1
  127. package/dist/server/capture-error.d.ts +8 -0
  128. package/dist/server/capture-error.d.ts.map +1 -1
  129. package/dist/server/capture-error.js.map +1 -1
  130. package/dist/server/core-routes-plugin.d.ts +0 -23
  131. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  132. package/dist/server/core-routes-plugin.js +71 -7
  133. package/dist/server/core-routes-plugin.js.map +1 -1
  134. package/dist/server/deploy-environment.d.ts +17 -0
  135. package/dist/server/deploy-environment.d.ts.map +1 -0
  136. package/dist/server/deploy-environment.js +47 -0
  137. package/dist/server/deploy-environment.js.map +1 -0
  138. package/dist/server/error-noise-filter.d.ts +80 -0
  139. package/dist/server/error-noise-filter.d.ts.map +1 -0
  140. package/dist/server/error-noise-filter.js +173 -0
  141. package/dist/server/error-noise-filter.js.map +1 -0
  142. package/dist/server/posthog-config.d.ts +28 -0
  143. package/dist/server/posthog-config.d.ts.map +1 -0
  144. package/dist/server/posthog-config.js +53 -0
  145. package/dist/server/posthog-config.js.map +1 -0
  146. package/dist/server/request-context.d.ts +6 -0
  147. package/dist/server/request-context.d.ts.map +1 -1
  148. package/dist/server/request-context.js.map +1 -1
  149. package/dist/server/sentry-config.d.ts +1 -0
  150. package/dist/server/sentry-config.d.ts.map +1 -1
  151. package/dist/server/sentry-config.js +3 -1
  152. package/dist/server/sentry-config.js.map +1 -1
  153. package/dist/server/sentry-plugin.d.ts.map +1 -1
  154. package/dist/server/sentry-plugin.js +12 -27
  155. package/dist/server/sentry-plugin.js.map +1 -1
  156. package/dist/server/sentry.d.ts.map +1 -1
  157. package/dist/server/sentry.js +8 -124
  158. package/dist/server/sentry.js.map +1 -1
  159. package/dist/server/ssr-handler.d.ts.map +1 -1
  160. package/dist/server/ssr-handler.js +6 -1
  161. package/dist/server/ssr-handler.js.map +1 -1
  162. package/dist/server/transcribe-voice.d.ts +1 -1
  163. package/dist/shared/mcp-embed-headers.d.ts +1 -1
  164. package/dist/shared/mcp-embed-headers.d.ts.map +1 -1
  165. package/dist/shared/mcp-embed-headers.js +1 -1
  166. package/dist/shared/mcp-embed-headers.js.map +1 -1
  167. package/dist/templates/workspace-core/.agents/skills/observability/SKILL.md +36 -10
  168. package/dist/templates/workspace-core/.agents/skills/tracking/SKILL.md +55 -1
  169. package/dist/tracking/error-capture.d.ts +7 -0
  170. package/dist/tracking/error-capture.d.ts.map +1 -1
  171. package/dist/tracking/error-capture.js +10 -73
  172. package/dist/tracking/error-capture.js.map +1 -1
  173. package/dist/tracking/index.d.ts +1 -0
  174. package/dist/tracking/index.d.ts.map +1 -1
  175. package/dist/tracking/index.js +1 -0
  176. package/dist/tracking/index.js.map +1 -1
  177. package/dist/tracking/posthog-exception.d.ts +103 -0
  178. package/dist/tracking/posthog-exception.d.ts.map +1 -0
  179. package/dist/tracking/posthog-exception.js +181 -0
  180. package/dist/tracking/posthog-exception.js.map +1 -0
  181. package/dist/tracking/providers.d.ts +14 -0
  182. package/dist/tracking/providers.d.ts.map +1 -1
  183. package/dist/tracking/providers.js +63 -11
  184. package/dist/tracking/providers.js.map +1 -1
  185. package/dist/tracking/redaction.d.ts +25 -0
  186. package/dist/tracking/redaction.d.ts.map +1 -0
  187. package/dist/tracking/redaction.js +85 -0
  188. package/dist/tracking/redaction.js.map +1 -0
  189. package/docs/content/observability.mdx +41 -1
  190. package/docs/content/template-content-developers.mdx +1 -1
  191. package/docs/content/tracking.mdx +25 -0
  192. package/package.json +1 -1
  193. package/src/agent/run-manager.ts +7 -0
  194. package/src/client/agent-chat-adapter.ts +10 -0
  195. package/src/client/analytics.ts +105 -1
  196. package/src/deploy/build.ts +38 -1
  197. package/src/observability/posthog-ai.ts +294 -0
  198. package/src/observability/routes.ts +29 -6
  199. package/src/observability/traces.ts +287 -12
  200. package/src/observability/types.ts +11 -0
  201. package/src/secrets/register-framework-secrets.ts +16 -0
  202. package/src/server/agent-run-context.ts +21 -0
  203. package/src/server/capture-error.ts +8 -0
  204. package/src/server/core-routes-plugin.ts +75 -4
  205. package/src/server/deploy-environment.ts +57 -0
  206. package/src/server/error-noise-filter.ts +268 -0
  207. package/src/server/posthog-config.ts +75 -0
  208. package/src/server/request-context.ts +6 -0
  209. package/src/server/sentry-config.ts +4 -8
  210. package/src/server/sentry-plugin.ts +13 -29
  211. package/src/server/sentry.ts +13 -156
  212. package/src/server/ssr-handler.ts +6 -1
  213. package/src/shared/mcp-embed-headers.ts +1 -1
  214. package/src/templates/workspace-core/.agents/skills/observability/SKILL.md +36 -10
  215. package/src/templates/workspace-core/.agents/skills/tracking/SKILL.md +55 -1
  216. package/src/tracking/error-capture.ts +44 -109
  217. package/src/tracking/index.ts +11 -0
  218. package/src/tracking/posthog-exception.ts +305 -0
  219. package/src/tracking/providers.ts +91 -14
  220. package/src/tracking/redaction.ts +105 -0
  221. package/corpus/templates/content/actions/delete-database-items.ts +0 -89
@@ -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
 
@@ -1,8 +1,11 @@
1
- import { useEffect, useRef, useState } from "react";
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
2
 
3
3
  import {
4
+ appendFinalTranscript,
5
+ isMicEcho,
4
6
  onFinalTranscript,
5
7
  onPartialTranscript,
8
+ type TranscriptLine,
6
9
  } from "../lib/transcription-engine";
7
10
 
8
11
  type Source = "mic" | "system";
@@ -13,6 +16,17 @@ export interface FinalLine {
13
16
  startMs?: number;
14
17
  }
15
18
 
19
+ /** Preloaded history arrives without its verbatim segments. */
20
+ function historyLine(line: FinalLine): TranscriptLine {
21
+ return {
22
+ text: line.text,
23
+ source: line.source,
24
+ startMs: line.startMs ?? null,
25
+ segments: [],
26
+ historical: true,
27
+ };
28
+ }
29
+
16
30
  /** Format ms since meeting start as m:ss. */
17
31
  function formatTimestamp(ms: number): string {
18
32
  const total = Math.floor(ms / 1000);
@@ -38,10 +52,12 @@ export function LiveTranscript({
38
52
  onLinesChange,
39
53
  initialLines,
40
54
  }: {
41
- onLinesChange?: (lines: FinalLine[]) => void;
55
+ onLinesChange?: (lines: TranscriptLine[]) => void;
42
56
  initialLines?: FinalLine[];
43
57
  } = {}) {
44
- const [finals, setFinals] = useState<FinalLine[]>(initialLines ?? []);
58
+ const [finals, setFinals] = useState<TranscriptLine[]>(
59
+ () => initialLines?.map(historyLine) ?? [],
60
+ );
45
61
  const [micPartial, setMicPartial] = useState("");
46
62
  const [sysPartial, setSysPartial] = useState("");
47
63
  const scrollRef = useRef<HTMLDivElement | null>(null);
@@ -69,7 +85,7 @@ export function LiveTranscript({
69
85
  // older history), instead of being overwritten.
70
86
  if (preloadAppliedRef.current) return;
71
87
  preloadAppliedRef.current = true;
72
- setFinals((prev) => [...lines, ...prev]);
88
+ setFinals((prev) => [...lines.map(historyLine), ...prev]);
73
89
  }, [initialLines]);
74
90
 
75
91
  useEffect(() => {
@@ -97,12 +113,13 @@ export function LiveTranscript({
97
113
  }),
98
114
  );
99
115
  trackListen(
100
- onFinalTranscript(({ text, source, segments }) => {
101
- const txt = text.trim();
102
- if (!txt) return;
103
- const startMs = segments[0]?.startMs;
104
- setFinals((prev) => [...prev, { text: txt, source, startMs }]);
105
- if (source === "system") setSysPartial("");
116
+ onFinalTranscript((event) => {
117
+ if (!event.text.trim()) return;
118
+ setFinals((prev) => {
119
+ const next = [...prev];
120
+ return appendFinalTranscript(event, next) ? next : prev;
121
+ });
122
+ if (event.source === "system") setSysPartial("");
106
123
  else setMicPartial("");
107
124
  }),
108
125
  );
@@ -126,9 +143,21 @@ export function LiveTranscript({
126
143
  el.scrollTop = el.scrollHeight;
127
144
  }, [finals, micPartial, sysPartial]);
128
145
 
146
+ // Partials never reach `appendFinalTranscript`, so speaker bleed shows up
147
+ // here as a live "You" bubble mirroring what the remote side is still
148
+ // saying. Judge the in-flight mic text against the in-flight system text too,
149
+ // since neither has been committed to a line yet.
150
+ const spokenMicPartial = useMemo(() => {
151
+ if (!micPartial) return "";
152
+ const inFlight: TranscriptLine[] = sysPartial
153
+ ? [{ source: "system", text: sysPartial, startMs: null, segments: [] }]
154
+ : [];
155
+ return isMicEcho(micPartial, [...finals, ...inFlight]) ? "" : micPartial;
156
+ }, [micPartial, sysPartial, finals]);
157
+
129
158
  return (
130
159
  <div ref={scrollRef} className="lt-chat">
131
- {finals.length === 0 && !micPartial && !sysPartial ? (
160
+ {finals.length === 0 && !spokenMicPartial && !sysPartial ? (
132
161
  <div className="lt-empty">Listening…</div>
133
162
  ) : null}
134
163
  {finals.map((line, i) => (
@@ -142,8 +171,8 @@ export function LiveTranscript({
142
171
  {sysPartial ? (
143
172
  <ChatBubble source="system" text={sysPartial} pending />
144
173
  ) : null}
145
- {micPartial ? (
146
- <ChatBubble source="mic" text={micPartial} pending />
174
+ {spokenMicPartial ? (
175
+ <ChatBubble source="mic" text={spokenMicPartial} pending />
147
176
  ) : null}
148
177
  </div>
149
178
  );
@@ -164,7 +193,7 @@ function ChatBubble({
164
193
  source: Source;
165
194
  text: string;
166
195
  pending?: boolean;
167
- startMs?: number;
196
+ startMs?: number | null;
168
197
  }) {
169
198
  const isYou = source === "mic";
170
199
  const label = isYou ? "You" : "Them";
@@ -17,7 +17,7 @@ import { getCurrentWindow } from "@tauri-apps/api/window";
17
17
  import { useCallback, useEffect, useRef, useState } from "react";
18
18
 
19
19
  import { isDirectPillClick, type ScreenPoint } from "../lib/pill-interaction";
20
- import { speakerFor } from "../lib/transcription-engine";
20
+ import { speakerFor, type TranscriptLine } from "../lib/transcription-engine";
21
21
  import { LiveAudioBars } from "./live-audio-bars";
22
22
  import { LiveTranscript, type FinalLine } from "./live-transcript";
23
23
  import { PillLogo } from "./pill-logo";
@@ -52,7 +52,7 @@ export function RecordingPill() {
52
52
  );
53
53
  const finished = finishedMeetingId !== null;
54
54
  const [error, setError] = useState<string | null>(null);
55
- const transcriptLinesRef = useRef<FinalLine[]>([]);
55
+ const transcriptLinesRef = useRef<TranscriptLine[]>([]);
56
56
  const [hasTranscriptLines, setHasTranscriptLines] = useState(false);
57
57
  const [transcriptCopied, setTranscriptCopied] = useState(false);
58
58
  const [preloadedLines, setPreloadedLines] = useState<FinalLine[]>([]);
@@ -269,7 +269,7 @@ export function RecordingPill() {
269
269
 
270
270
  // Stable callback for LiveTranscript to push locked-in lines up. Stable
271
271
  // identity matters — it's a dep of an effect inside LiveTranscript.
272
- const handleTranscriptLines = useCallback((lines: FinalLine[]) => {
272
+ const handleTranscriptLines = useCallback((lines: TranscriptLine[]) => {
273
273
  transcriptLinesRef.current = lines;
274
274
  setHasTranscriptLines(lines.length > 0);
275
275
  }, []);