@hraness/dawg 0.6.1 → 0.7.0

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 (253) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/DAWG.md +343 -116
  3. package/README.md +27 -25
  4. package/core/autotune.ts +1119 -0
  5. package/core/chords.ts +271 -23
  6. package/core/clips.ts +499 -0
  7. package/core/diff.ts +182 -104
  8. package/core/expression.ts +15 -0
  9. package/core/fx.ts +99 -29
  10. package/core/instruments.ts +19 -0
  11. package/core/keys.ts +3 -3
  12. package/core/loop.ts +5 -0
  13. package/core/lyrics.ts +297 -0
  14. package/core/master.ts +3 -3
  15. package/core/resonators.ts +16 -2
  16. package/core/routing.ts +165 -0
  17. package/core/score.ts +832 -13
  18. package/core/sdk/eval-child.ts +7 -2
  19. package/core/sdk/eval.ts +35 -6
  20. package/core/sdk/print.ts +276 -3
  21. package/core/sdk/sync-lyrics.ts +49 -0
  22. package/core/sdk/v1.ts +1849 -42
  23. package/core/sections.ts +292 -26
  24. package/core/sing.ts +815 -0
  25. package/core/style-provenance.ts +80 -0
  26. package/core/styles/africa-mena-southasia.ts +2893 -0
  27. package/core/styles/americas.ts +3810 -0
  28. package/core/styles/art.ts +4993 -0
  29. package/core/styles/base.ts +123 -0
  30. package/core/styles/cycles.ts +106 -0
  31. package/core/styles/electronic.ts +2723 -0
  32. package/core/styles/europe-asia-pacific.ts +2838 -0
  33. package/core/styles/excerpt.ts +29 -0
  34. package/core/styles/gamelan.ts +283 -0
  35. package/core/styles/generate.ts +2199 -0
  36. package/core/styles/index.ts +515 -0
  37. package/core/styles/parts.ts +106 -0
  38. package/core/styles/pop.ts +3189 -0
  39. package/core/styles/rock.ts +2993 -0
  40. package/core/styles/roots.ts +4175 -0
  41. package/core/styles/schema.ts +429 -0
  42. package/core/styles/taxonomy.ts +940 -0
  43. package/core/styles/validate.ts +528 -0
  44. package/core/tempo.ts +32 -2
  45. package/core/tuning.ts +19 -3
  46. package/core/vocoder.ts +524 -0
  47. package/guides/agent.md +29 -0
  48. package/guides/arrange.md +30 -0
  49. package/guides/audition.md +20 -14
  50. package/guides/automation.md +12 -7
  51. package/guides/chords.md +15 -15
  52. package/guides/effects.md +17 -16
  53. package/guides/faders.md +20 -16
  54. package/guides/files.md +13 -9
  55. package/guides/getting-started.md +15 -11
  56. package/guides/keys.md +19 -15
  57. package/guides/media.md +17 -12
  58. package/guides/mix.md +12 -6
  59. package/guides/music.md +23 -8
  60. package/guides/notes.md +15 -9
  61. package/guides/performance.md +15 -13
  62. package/guides/play.md +21 -13
  63. package/guides/project.md +25 -8
  64. package/guides/providers.md +20 -14
  65. package/guides/resample.md +15 -9
  66. package/guides/rhythm.md +17 -13
  67. package/guides/sessions.md +17 -7
  68. package/guides/show-me.md +31 -0
  69. package/guides/sound.md +25 -9
  70. package/guides/sounds.md +15 -13
  71. package/guides/styles.md +31 -0
  72. package/guides/tempo.md +15 -10
  73. package/guides/tracks.md +15 -10
  74. package/guides/tuning.md +32 -0
  75. package/guides/voice.md +31 -0
  76. package/guides/web-search.md +18 -8
  77. package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
  78. package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
  79. package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
  80. package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
  81. package/native/prebuilt/manifest.json +21 -0
  82. package/package.json +5 -2
  83. package/src/agent/agent.ts +126 -14
  84. package/src/agent/calibration-tools.ts +53 -0
  85. package/src/agent/clip-tools.ts +453 -0
  86. package/src/agent/command-agent.ts +369 -0
  87. package/src/agent/drum-tools.ts +2 -2
  88. package/src/agent/expression-tools.ts +1 -1
  89. package/src/agent/gateway.ts +246 -60
  90. package/src/agent/models.ts +53 -12
  91. package/src/agent/ops.ts +12 -1
  92. package/src/agent/pack-tools.ts +1 -1
  93. package/src/agent/planner.ts +13 -0
  94. package/src/agent/portable-schema.ts +80 -0
  95. package/src/agent/preview-tool.ts +4 -1
  96. package/src/agent/provider.ts +22 -8
  97. package/src/agent/rhythm-tools.ts +1 -1
  98. package/src/agent/section-tools.ts +1 -1
  99. package/src/agent/show-me.ts +497 -0
  100. package/src/agent/steer.ts +15 -0
  101. package/src/agent/style-tools.ts +217 -0
  102. package/src/agent/tool-error.ts +12 -0
  103. package/src/agent/tools.ts +108 -23
  104. package/src/agent/usage.ts +2 -2
  105. package/src/agent/voice-tools.ts +925 -0
  106. package/src/agent/xcb-agent.ts +11 -7
  107. package/src/argv.ts +38 -0
  108. package/src/audio/analysis.ts +253 -0
  109. package/src/audio/arrange.ts +37 -3
  110. package/src/audio/autotune-engine.ts +101 -0
  111. package/src/audio/autotune.ts +640 -0
  112. package/src/audio/clips.ts +240 -0
  113. package/src/audio/doctor.ts +86 -0
  114. package/src/audio/dsp/bandbank.ts +138 -0
  115. package/src/audio/dsp/envelope.ts +10 -0
  116. package/src/audio/dsp/follow.ts +120 -0
  117. package/src/audio/dsp/formant.ts +427 -0
  118. package/src/audio/dsp/glottal.ts +243 -0
  119. package/src/audio/dsp/interp.ts +7 -2
  120. package/src/audio/dsp/lpc.ts +50 -0
  121. package/src/audio/dsp/periodicity.ts +59 -0
  122. package/src/audio/dsp/pitch.ts +995 -0
  123. package/src/audio/dsp/psola.ts +199 -0
  124. package/src/audio/effects/chain.ts +3 -1
  125. package/src/audio/effects/common.ts +43 -0
  126. package/src/audio/effects/convolution.ts +7 -4
  127. package/src/audio/effects/filter.ts +48 -69
  128. package/src/audio/effects/formant.ts +263 -0
  129. package/src/audio/engine.ts +160 -28
  130. package/src/audio/fit.ts +35 -3
  131. package/src/audio/instrument-check.ts +59 -43
  132. package/src/audio/instruments.ts +4 -0
  133. package/src/audio/keys/calibration.ts +56 -0
  134. package/src/audio/keys/electric.ts +8 -1
  135. package/src/audio/keys/engine.ts +13 -1
  136. package/src/audio/keys/piano.ts +22 -2
  137. package/src/audio/kits.ts +135 -6
  138. package/src/audio/live.ts +114 -20
  139. package/src/audio/native.ts +615 -0
  140. package/src/audio/preview.ts +30 -2
  141. package/src/audio/render-worker.ts +2 -0
  142. package/src/audio/renderer.ts +2 -0
  143. package/src/audio/resample.ts +2 -1
  144. package/src/audio/sampler.ts +85 -4
  145. package/src/audio/samples.ts +20 -2
  146. package/src/audio/sing/analysis.ts +193 -0
  147. package/src/audio/sing/engine.ts +949 -0
  148. package/src/audio/strings/bow.ts +48 -5
  149. package/src/audio/strings/engine.ts +8 -2
  150. package/src/audio/synth/oscillators.ts +31 -21
  151. package/src/audio/synth/voice.ts +34 -1
  152. package/src/audio/vocoder/bank.ts +314 -0
  153. package/src/audio/vocoder/carrier.ts +165 -0
  154. package/src/audio/vocoder/control.ts +68 -0
  155. package/src/audio/vocoder/detect.ts +50 -0
  156. package/src/audio/vocoder/index.ts +304 -0
  157. package/src/audio/vocoder/talkbox.ts +143 -0
  158. package/src/audio/wav.ts +500 -77
  159. package/src/audio/winds/engine.ts +5 -1
  160. package/src/audio/winds/trim.ts +28 -4
  161. package/src/audio/winds/trims1.ts +297 -0
  162. package/src/audio/winds/voice.ts +15 -2
  163. package/src/auth/cli.ts +38 -36
  164. package/src/auth/credentials.ts +30 -1
  165. package/src/auth/login.ts +15 -9
  166. package/src/auth/tui.ts +19 -10
  167. package/src/commands/arrange.ts +44 -29
  168. package/src/commands/autotune.ts +421 -0
  169. package/src/commands/calibration.ts +74 -0
  170. package/src/commands/clips.ts +887 -0
  171. package/src/commands/drums.ts +3 -2
  172. package/src/commands/edit.ts +11 -4
  173. package/src/commands/expression.ts +1 -1
  174. package/src/commands/formant.ts +221 -0
  175. package/src/commands/fx.ts +101 -32
  176. package/src/commands/grammar.ts +558 -0
  177. package/src/commands/help.ts +610 -380
  178. package/src/commands/history.ts +18 -0
  179. package/src/commands/keys.ts +8 -8
  180. package/src/commands/modal.ts +1 -1
  181. package/src/commands/music.ts +1 -1
  182. package/src/commands/nearest.ts +53 -0
  183. package/src/commands/pack.ts +9 -2
  184. package/src/commands/param-range.ts +56 -0
  185. package/src/commands/parses.ts +130 -0
  186. package/src/commands/progression.ts +170 -0
  187. package/src/commands/rhythm.ts +3 -0
  188. package/src/commands/rig.ts +3 -24
  189. package/src/commands/sing.ts +478 -0
  190. package/src/commands/strum.ts +13 -1
  191. package/src/commands/style.ts +415 -0
  192. package/src/commands/time.ts +6 -3
  193. package/src/commands/tuning.ts +3 -3
  194. package/src/commands/vocal-pitch.ts +616 -0
  195. package/src/commands/vocal.ts +147 -0
  196. package/src/commands/vocoder.ts +627 -0
  197. package/src/commands/wind.ts +2 -2
  198. package/src/fs/durable.ts +50 -0
  199. package/src/lang/glossary.ts +493 -0
  200. package/src/launch-args.ts +163 -0
  201. package/src/main.ts +1397 -253
  202. package/src/media/cli.ts +20 -3
  203. package/src/media/import.ts +3 -1
  204. package/src/project/check.ts +21 -2
  205. package/src/project/clip-pins.ts +72 -0
  206. package/src/project/init.ts +23 -8
  207. package/src/project/sync.ts +418 -86
  208. package/src/render.ts +20 -1
  209. package/src/session/daemon.ts +3 -0
  210. package/src/session/meta.ts +14 -0
  211. package/src/session/origin.ts +154 -0
  212. package/src/session/port.ts +22 -4
  213. package/src/session/presence.ts +34 -4
  214. package/src/session/protocol.ts +5 -1
  215. package/src/session/rebase.ts +18 -5
  216. package/src/session/receipt.ts +258 -0
  217. package/src/session/store.ts +65 -43
  218. package/src/tui/arrange-menu.ts +65 -31
  219. package/src/tui/audition.ts +1 -1
  220. package/src/tui/euclid.ts +18 -13
  221. package/src/tui/fader.ts +228 -41
  222. package/src/tui/granular-menu.ts +2 -4
  223. package/src/tui/menu-clips.ts +297 -0
  224. package/src/tui/menu-time.ts +20 -13
  225. package/src/tui/menu-voice.ts +405 -0
  226. package/src/tui/menu.ts +759 -177
  227. package/src/tui/modal-menu.ts +6 -6
  228. package/src/tui/performance-menu.ts +5 -2
  229. package/src/tui/play-chords.ts +4 -2
  230. package/src/tui/play-mode.ts +15 -1
  231. package/src/tui/play-session.ts +47 -7
  232. package/src/tui/sing-menu.ts +278 -0
  233. package/src/tui/style-menu.ts +104 -0
  234. package/src/tui/vocoder-menu.ts +244 -0
  235. package/src/tui/wind-menu.ts +3 -3
  236. package/src/version.ts +8 -0
  237. package/src/web/fetch.ts +115 -29
  238. package/tui/activity.ts +180 -9
  239. package/tui/app.ts +274 -40
  240. package/tui/clip-row.ts +132 -0
  241. package/tui/delight.ts +144 -0
  242. package/tui/drawer.ts +70 -22
  243. package/tui/frame-gate.ts +76 -0
  244. package/tui/grammar.ts +112 -63
  245. package/tui/guide.ts +42 -4
  246. package/tui/highway.ts +269 -25
  247. package/tui/hints.ts +192 -0
  248. package/tui/input.ts +60 -9
  249. package/tui/keys.ts +1 -1
  250. package/tui/play-strip.ts +68 -14
  251. package/tui/prompt.ts +1 -1
  252. package/tui/screen.ts +144 -14
  253. package/tui/theme.ts +27 -0
package/tui/activity.ts CHANGED
@@ -23,6 +23,33 @@ export interface ActivityCard {
23
23
  trackId?: string | undefined;
24
24
  /** Key hint shown after the card, e.g. "u undo". */
25
25
  hint?: string | undefined;
26
+ /**
27
+ * A one-time info card (a launch note, a tip): it leaves the strip after
28
+ * `ONCE_TTL_MS` or `ONCE_ACTIONS` later actions, whichever comes first.
29
+ */
30
+ once?: boolean | undefined;
31
+ /** Repeats of the same text and tone, shown as `×N` (absent is 1). */
32
+ count?: number | undefined;
33
+ /** A quieter note joined to the card, e.g. the auto-name. */
34
+ suffix?: string | undefined;
35
+ /** `actions` when the card was pushed, for the `once` expiry. */
36
+ atAction?: number | undefined;
37
+ }
38
+
39
+ /** A `once` card's lifetime in milliseconds. */
40
+ export const ONCE_TTL_MS = 8_000;
41
+ /** Actions (receipts, requests) after which a `once` card leaves. */
42
+ export const ONCE_ACTIONS = 3;
43
+ /** Age after which an older error fades to the faint role. */
44
+ export const ERROR_FADE_MS = 6_000;
45
+
46
+ /** One strip line: newlines read as ` · `, runs of space collapse. */
47
+ export function stripLine(text: string): string {
48
+ return text
49
+ .split(/\s*\n\s*/)
50
+ .map((part) => part.replace(/[ \t]+/g, " ").trim())
51
+ .filter(Boolean)
52
+ .join(" · ");
26
53
  }
27
54
 
28
55
  export type TranscriptKind =
@@ -134,7 +161,10 @@ export class ActivityFeed {
134
161
  private spinnerLabel: string | undefined;
135
162
  private spinnerSince = 0;
136
163
  private streamText = "";
164
+ private turnReceipt = "";
137
165
  private queue = 0;
166
+ /** Receipts and requests so far: the clock `once` cards expire on. */
167
+ private actions = 0;
138
168
  private listeners = new Set<() => void>();
139
169
  /** Increments on every change; renderers can skip work when unchanged. */
140
170
  version = 0;
@@ -167,6 +197,36 @@ export class ActivityFeed {
167
197
  return this.cardList[this.cardList.length - 1];
168
198
  }
169
199
 
200
+ /**
201
+ * The cards the strip shows at `nowMs`, first slot first: the newest
202
+ * receipt leads, live `once` notes follow it, and expired `once` notes
203
+ * are gone.
204
+ */
205
+ visible(nowMs: number = this.clock()): ActivityCard[] {
206
+ const live = this.cardList.filter(
207
+ (card) =>
208
+ !card.once ||
209
+ (nowMs - card.atMs < ONCE_TTL_MS &&
210
+ this.actions - (card.atAction ?? 0) < ONCE_ACTIONS),
211
+ );
212
+ const newest = [...live].reverse();
213
+ return [
214
+ ...newest.filter((card) => !card.once),
215
+ ...newest.filter((card) => card.once),
216
+ ];
217
+ }
218
+
219
+ /** The next time a `once` card expires on the clock, for frame wakeups. */
220
+ nextExpiry(nowMs: number = this.clock()): number | undefined {
221
+ let next: number | undefined;
222
+ for (const card of this.cardList) {
223
+ if (!card.once) continue;
224
+ const at = card.atMs + ONCE_TTL_MS;
225
+ if (at > nowMs && (next === undefined || at < next)) next = at;
226
+ }
227
+ return next;
228
+ }
229
+
170
230
  subscribe(listener: () => void): () => void {
171
231
  this.listeners.add(listener);
172
232
  return () => this.listeners.delete(listener);
@@ -176,32 +236,85 @@ export class ActivityFeed {
176
236
  text: string,
177
237
  details: Omit<Partial<ActivityCard>, "id" | "atMs" | "text"> = {},
178
238
  ): ActivityCard {
239
+ const line = stripLine(text);
240
+ const once = details.once === true;
241
+ if (!once) this.actions += 1;
242
+ const last = this.cardList[this.cardList.length - 1];
243
+ const tone = details.tone ?? "info";
244
+ // The same message again reads `×N` instead of filling the strip.
245
+ if (
246
+ last &&
247
+ last.text === line &&
248
+ last.tone === tone &&
249
+ last.resultRevision === details.resultRevision
250
+ ) {
251
+ const repeat: ActivityCard = {
252
+ ...last,
253
+ count: (last.count ?? 1) + 1,
254
+ atMs: this.clock(),
255
+ atAction: this.actions,
256
+ };
257
+ this.cardList = [...this.cardList.slice(0, -1), repeat];
258
+ this.record(tone === "error" ? "error" : "op", line);
259
+ this.changed();
260
+ return repeat;
261
+ }
179
262
  const card: ActivityCard = {
180
263
  id: this.nextId++,
181
- text,
182
- tone: details.tone ?? "info",
264
+ text: line,
265
+ tone,
183
266
  atMs: this.clock(),
184
267
  baseRevision: details.baseRevision,
185
268
  resultRevision: details.resultRevision,
186
269
  trackId: details.trackId,
187
270
  hint: details.hint,
271
+ ...(once ? { once: true } : {}),
272
+ ...(details.suffix ? { suffix: stripLine(details.suffix) } : {}),
273
+ atAction: this.actions,
188
274
  };
189
275
  this.cardList = [...this.cardList, card].slice(-this.maxCards);
190
276
  const revision = revisionLabel(card.baseRevision, card.resultRevision);
191
277
  this.record(
192
278
  card.tone === "error" ? "error" : "op",
193
- revision ? `${text} · ${revision}` : text,
279
+ revision ? `${line} · ${revision}` : line,
194
280
  );
195
281
  this.changed();
196
282
  return card;
197
283
  }
198
284
 
285
+ /**
286
+ * Join a quiet note to the newest receipt (`… · named "dusk loop"`), so
287
+ * it rides in the first slot instead of pushing the receipt aside. With
288
+ * no receipt in the last few seconds it becomes a `once` card.
289
+ */
290
+ attachNote(text: string, withinMs = ONCE_TTL_MS): ActivityCard {
291
+ const line = stripLine(text);
292
+ const last = this.cardList[this.cardList.length - 1];
293
+ if (
294
+ last &&
295
+ !last.once &&
296
+ last.tone !== "error" &&
297
+ this.clock() - last.atMs < withinMs
298
+ ) {
299
+ const joined: ActivityCard = {
300
+ ...last,
301
+ suffix: last.suffix ? `${last.suffix} · ${line}` : line,
302
+ };
303
+ this.cardList = [...this.cardList.slice(0, -1), joined];
304
+ this.record("note", line);
305
+ this.changed();
306
+ return joined;
307
+ }
308
+ return this.pushCard(line, { tone: "info", once: true });
309
+ }
310
+
199
311
  pushError(message: string): ActivityCard {
200
312
  return this.pushCard(message, { tone: "error" });
201
313
  }
202
314
 
203
315
  /** Record a user request in the transcript without adding a strip card. */
204
316
  pushRequest(text: string): void {
317
+ this.actions += 1;
205
318
  this.record("request", text);
206
319
  this.changed();
207
320
  }
@@ -227,6 +340,23 @@ export class ActivityFeed {
227
340
  this.changed();
228
341
  }
229
342
 
343
+ /**
344
+ * The musical receipt of the running agent turn (`musicalReceipt` of the
345
+ * score before and after), shown as the turn's closing card.
346
+ */
347
+ setTurnReceipt(text: string): void {
348
+ this.turnReceipt = text;
349
+ }
350
+
351
+ /**
352
+ * The latest prose sentence of the streaming agent text, for the faint
353
+ * tail of the spinner: a complete sentence when one has ended, else the
354
+ * one being written.
355
+ */
356
+ get streamSentence(): string {
357
+ return latestSentence(this.streamText);
358
+ }
359
+
230
360
  /** Adapter for the agent lane's streaming events. */
231
361
  applyAgentEvent(event: AgentActivityEvent): void {
232
362
  switch (event.type) {
@@ -271,7 +401,7 @@ export class ActivityFeed {
271
401
  baseRevision: event.baseRevision,
272
402
  resultRevision: event.resultRevision,
273
403
  trackId: event.trackId,
274
- hint: unchanged ? undefined : "^z undo",
404
+ hint: unchanged ? undefined : "ctrl-z undo",
275
405
  });
276
406
  return;
277
407
  }
@@ -287,9 +417,20 @@ export class ActivityFeed {
287
417
  case "done": {
288
418
  if (this.streamText) this.record("agent", this.streamText);
289
419
  this.streamText = "";
420
+ // A turn that changed the music ends on what it changed, in musical
421
+ // terms; its prose stays in the ctrl-o log.
422
+ const receipt = this.turnReceipt;
423
+ this.turnReceipt = "";
290
424
  const summary =
291
- event.summary ?? event.text?.split("\n")[0]?.slice(0, 160);
292
- if (summary) this.pushCard(summary, { tone: "agent" });
425
+ receipt ||
426
+ event.summary ||
427
+ firstSentence(event.text ?? "").slice(0, 160) ||
428
+ undefined;
429
+ if (summary)
430
+ this.pushCard(summary, {
431
+ tone: "agent",
432
+ hint: receipt ? "ctrl-z undo" : undefined,
433
+ });
293
434
  else if (event.applied === 0)
294
435
  this.pushCard("agent made no changes", { tone: "info" });
295
436
  this.setSpinner(undefined);
@@ -300,7 +441,7 @@ export class ActivityFeed {
300
441
  this.setSpinner(undefined);
301
442
  if (event.code === "aborted")
302
443
  this.pushCard(
303
- `cancelled${event.revision === undefined ? "" : ` · kept rev ${event.revision}`}`,
444
+ `canceled${event.revision === undefined ? "" : ` · kept rev ${event.revision}`}`,
304
445
  { tone: "warning" },
305
446
  );
306
447
  else
@@ -331,11 +472,16 @@ export class ActivityFeed {
331
472
  * success, `false` a failure, `"warn"` a no-op or conflict. Handlers return
332
473
  * these so the strip never infers failure from prose.
333
474
  */
334
- export type Receipt = Readonly<{ ok: boolean | "warn"; text: string }>;
475
+ export type Receipt = Readonly<{
476
+ ok: boolean | "warn" | "info";
477
+ text: string;
478
+ }>;
335
479
 
336
480
  export const ok = (text: string): Receipt => ({ ok: true, text });
337
481
  export const fail = (text: string): Receipt => ({ ok: false, text });
338
482
  export const warn = (text: string): Receipt => ({ ok: "warn", text });
483
+ /** A status read (`•`): nothing changed (bare `fx`, `tracks`). */
484
+ export const note = (text: string): Receipt => ({ ok: "info", text });
339
485
 
340
486
  /** Tone of a structured receipt; strings fall back to `receiptTone`. */
341
487
  export function toneOf(receipt: string | Receipt): CardTone {
@@ -344,7 +490,9 @@ export function toneOf(receipt: string | Receipt): CardTone {
344
490
  ? "success"
345
491
  : receipt.ok === "warn"
346
492
  ? "warning"
347
- : "error";
493
+ : receipt.ok === "info"
494
+ ? "info"
495
+ : "error";
348
496
  }
349
497
 
350
498
  /**
@@ -362,3 +510,26 @@ export function receiptTone(message: string): CardTone {
362
510
  return "warning";
363
511
  return "success";
364
512
  }
513
+
514
+ /** The first sentence of a reply (up to its first `.`, `!` or `?`). */
515
+ export function firstSentence(text: string): string {
516
+ const flat = text.replace(/\s+/g, " ").trim();
517
+ const match = /^.*?[.!?](?=\s|$)/.exec(flat);
518
+ return (match ? match[0] : flat).trim();
519
+ }
520
+
521
+ /** The last complete sentence of streaming text, or the one in progress. */
522
+ export function latestSentence(text: string): string {
523
+ const flat = text.replace(/\s+/g, " ").trim();
524
+ if (!flat) return "";
525
+ const sentences = flat.match(/[^.!?]+[.!?]+(?=\s|$)|[^.!?]+$/g) ?? [flat];
526
+ const last = sentences[sentences.length - 1]!.trim();
527
+ // A fragment of a few words reads as noise; keep the sentence before it.
528
+ if (
529
+ !/[.!?]$/.test(last) &&
530
+ last.split(" ").length < 4 &&
531
+ sentences.length > 1
532
+ )
533
+ return sentences[sentences.length - 2]!.trim();
534
+ return last;
535
+ }