@ossclip/core 0.1.15 → 0.1.17

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossclip/core",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "description": "ossclip's framework-free pipeline: schema, transcription, analysis, cutlist, captions, framing, and the LLM producer",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/blooper.ts CHANGED
@@ -92,12 +92,25 @@ function matchMarker(wordText: string, want: string): MarkerMatch | null {
92
92
  if (!norm) return null;
93
93
  if (norm === want) return { surface: norm, exact: true };
94
94
  if (want.length < FUZZY_MIN_MARKER_LEN) return null;
95
+ // A plain English inflection of the marker is a REAL word the speaker can
96
+ // say on purpose — "it removes the bloopers", describing the feature, sits
97
+ // at distance 1 from a "blooper" marker and the fuzzy arm cut 7.08s of a
98
+ // good announce take back to its sentence start (FINDINGS §133). Fuzzy
99
+ // exists for ASR mishearings of the SPOKEN marker; an inflection is far
100
+ // more likely content, so it stays exact-only, in both directions.
101
+ if (isPluralPair(norm, want)) return null;
95
102
  if (levenshtein(norm, want) <= FUZZY_MAX_DISTANCE) {
96
103
  return { surface: norm, exact: false };
97
104
  }
98
105
  return null;
99
106
  }
100
107
 
108
+ /** Whether one token is the plain s/es plural of the other. */
109
+ function isPluralPair(a: string, b: string): boolean {
110
+ const [short, long] = a.length <= b.length ? [a, b] : [b, a];
111
+ return long === `${short}s` || long === `${short}es`;
112
+ }
113
+
101
114
  /**
102
115
  * Spans the speaker marked as bloopers.
103
116
  *
@@ -0,0 +1,220 @@
1
+ import { z } from "zod/v4";
2
+ import { run } from "../exec";
3
+ import type { LlmProvider } from "./provider";
4
+ import { estimateTokens, type LlmUsage } from "./usage";
5
+ // Shared fence-stripper for replies that wrap the JSON in prose/markdown.
6
+ // A formatting concern, not a Claude dependency (FINDINGS §132, antigravity
7
+ // provider).
8
+ import { extractJsonObject } from "./claude-cli";
9
+
10
+ /**
11
+ * agy's own print timeout defaults to 5m, which a beat-sheet call on a long
12
+ * transcript can exceed. Core's `run()` has no timeout of its own, so this
13
+ * flag is the ONLY clock on the spawn — without it a stuck call is a hang,
14
+ * with it a timeout surfaces as retry-then-throw (FINDINGS §132, antigravity
15
+ * provider).
16
+ */
17
+ export const AGY_PRINT_TIMEOUT = "10m";
18
+
19
+ /**
20
+ * agy takes the prompt as an argv argument only — no stdin — and macOS caps
21
+ * ARG_MAX around 1MB. Refuse before the OS does: a pre-spawn check turns
22
+ * E2BIG into a directed error naming providers that can take the prompt
23
+ * (FINDINGS §132, antigravity provider).
24
+ */
25
+ export const MAX_AGY_PROMPT_BYTES = 700_000;
26
+
27
+ /**
28
+ * The argv for one `agy` print-mode call. Pure so the flag set is testable
29
+ * without spawning anything. `--disable-slash-commands` because a transcript
30
+ * prompt that happens to start with `/` must not expand as a skill.
31
+ */
32
+ export function buildAgyArgs(
33
+ prompt: string,
34
+ opts: { model?: string; schemaJson: string },
35
+ ): string[] {
36
+ return [
37
+ "-p",
38
+ prompt,
39
+ "--output-format",
40
+ "json",
41
+ "--disable-slash-commands",
42
+ "--json-schema",
43
+ opts.schemaJson,
44
+ "--print-timeout",
45
+ AGY_PRINT_TIMEOUT,
46
+ ...(opts.model ? ["--model", opts.model] : []),
47
+ ];
48
+ }
49
+
50
+ /**
51
+ * The agy JSON envelope, tolerated rather than trusted: every field absent is
52
+ * a valid outcome — the caller falls back to estimates and says so — so this
53
+ * never throws on a shape it doesn't recognise (same contract as
54
+ * `parseCliEnvelope`).
55
+ *
56
+ * Token mapping into `LlmUsage` terms:
57
+ * - `outputTokens` folds `thinking_tokens` in: thinking is output-side
58
+ * spend and LlmUsage has no thinking field of its own.
59
+ * - Whether `input_tokens` already includes cache reads is not documented,
60
+ * so it is cross-checked against `total_tokens`: if
61
+ * input + output + thinking + cache_read exceeds the total, input is
62
+ * already cache-inclusive and stands alone; otherwise cache reads are
63
+ * added. Worst case is a conservative over-count — the safe direction
64
+ * for a number a user might budget against (FINDINGS §132, antigravity
65
+ * provider).
66
+ */
67
+ export function parseAgyEnvelope(stdout: string): {
68
+ status?: string;
69
+ response?: string;
70
+ error?: string;
71
+ structuredOutput?: unknown;
72
+ inputTokens?: number;
73
+ outputTokens?: number;
74
+ cachedInputTokens?: number;
75
+ } {
76
+ let env: Record<string, unknown>;
77
+ try {
78
+ env = JSON.parse(stdout.trim()) as Record<string, unknown>;
79
+ } catch {
80
+ return {};
81
+ }
82
+ if (!env || typeof env !== "object") return {};
83
+ const num = (v: unknown): number | undefined => (typeof v === "number" ? v : undefined);
84
+ const str = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined);
85
+ const u = (env.usage ?? {}) as Record<string, unknown>;
86
+ const input = num(u.input_tokens);
87
+ const output = num(u.output_tokens);
88
+ const thinking = num(u.thinking_tokens) ?? 0;
89
+ const cacheRead = num(u.cache_read_tokens) ?? 0;
90
+ const total = num(u.total_tokens);
91
+ const cacheInclusive =
92
+ input !== undefined && total !== undefined
93
+ ? input + (output ?? 0) + thinking + cacheRead > total
94
+ : false;
95
+ return {
96
+ status: str(env.status),
97
+ response: str(env.response),
98
+ error: str(env.error),
99
+ structuredOutput: env.structured_output,
100
+ inputTokens: input === undefined ? undefined : cacheInclusive ? input : input + cacheRead,
101
+ outputTokens: output === undefined ? undefined : output + thinking,
102
+ cachedInputTokens: cacheRead || undefined,
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Failures a retry cannot fix: missing auth and a bad model slug are
108
+ * deterministic, and each retry burns another ~24k-token baseline call (agy's
109
+ * own agent context) for nothing (FINDINGS §132, antigravity provider).
110
+ */
111
+ export function isNonRetryableAgyFailure(message: string): boolean {
112
+ return /authentication|not logged in|login|unknown model|invalid model/i.test(message);
113
+ }
114
+
115
+ /**
116
+ * Google Antigravity via the locally installed `agy` CLI. Uses whatever auth
117
+ * the CLI holds — a logged-in subscription, so producing a video consumes
118
+ * plan usage rather than pay-per-token API credits (`billed: false`, and agy
119
+ * reports no cost of its own).
120
+ *
121
+ * The schema rides twice: `--json-schema` for server-side enforcement AND
122
+ * stated in the prompt — the prompt copy is what makes the self-repair retry
123
+ * meaningful, and server enforcement is not our contract, so the reply is
124
+ * still zod-validated here.
125
+ *
126
+ * Requirements: `agy` on PATH (or OSSCLIP_AGY_BIN) and a prior interactive
127
+ * sign-in. The envelope carries no model id, so usage records the requested
128
+ * model or "antigravity-default" (FINDINGS §132, antigravity provider).
129
+ */
130
+ export class AntigravityProvider implements LlmProvider {
131
+ readonly name = "antigravity";
132
+ readonly usage: LlmUsage[] = [];
133
+
134
+ constructor(
135
+ private model?: string,
136
+ private bin: string = process.env.OSSCLIP_AGY_BIN ?? "agy",
137
+ ) {}
138
+
139
+ async complete<T>(req: {
140
+ system: string;
141
+ user: string;
142
+ schema: z.ZodType<T>;
143
+ schemaName: string;
144
+ }): Promise<T> {
145
+ const schemaText = JSON.stringify(z.toJSONSchema(req.schema));
146
+ const base =
147
+ `${req.system}\n\n${req.user}\n\n` +
148
+ `Respond with ONLY a JSON object valid against this JSON Schema ("${req.schemaName}"). ` +
149
+ `No markdown fences, no commentary, no tool use — just the JSON:\n${schemaText}`;
150
+
151
+ let lastError = "";
152
+ for (let attempt = 0; attempt < 2; attempt++) {
153
+ const prompt =
154
+ attempt === 0
155
+ ? base
156
+ : `${base}\n\nYour previous reply failed validation:\n${lastError}\nReturn ONLY the corrected JSON.`;
157
+ const promptBytes = Buffer.byteLength(prompt, "utf8");
158
+ if (promptBytes > MAX_AGY_PROMPT_BYTES) {
159
+ throw new Error(
160
+ `prompt is ${promptBytes.toLocaleString("en-US")} bytes, over the ${MAX_AGY_PROMPT_BYTES.toLocaleString("en-US")}-byte limit for agy — ` +
161
+ `it accepts the prompt only as a command-line argument. ` +
162
+ `Use --llm claude-cli or --llm gemini for a take this long.`,
163
+ );
164
+ }
165
+ const started = Date.now();
166
+ let stdout = "";
167
+ try {
168
+ ({ stdout } = await run(
169
+ this.bin,
170
+ buildAgyArgs(prompt, { model: this.model, schemaJson: schemaText }),
171
+ ));
172
+ } catch (err) {
173
+ lastError = err instanceof Error ? err.message : String(err);
174
+ // run() embeds the stderr tail in its rejection, so auth/bad-slug
175
+ // failures are matchable here and fail fast instead of re-spending.
176
+ if (isNonRetryableAgyFailure(lastError)) break;
177
+ continue;
178
+ }
179
+ // Recorded per ATTEMPT, before validation: a reply that failed the
180
+ // schema still spent the tokens, and a retry is exactly the cost a user
181
+ // would want to see rather than have quietly absorbed.
182
+ const envelope = parseAgyEnvelope(stdout);
183
+ this.usage.push({
184
+ provider: this.name,
185
+ // The envelope names no model, so record what was asked for — or the
186
+ // honest placeholder, which the cost report declines to price.
187
+ model: this.model ?? "antigravity-default",
188
+ schemaName: req.schemaName,
189
+ inputTokens: envelope.inputTokens ?? estimateTokens(prompt),
190
+ outputTokens: envelope.outputTokens ?? estimateTokens(envelope.response ?? stdout),
191
+ cachedInputTokens: envelope.cachedInputTokens,
192
+ exact: envelope.inputTokens !== undefined,
193
+ // The whole point of this provider: agy's cached sign-in means the
194
+ // subscription pays, not a card, and agy reports no cost to forward.
195
+ billed: false,
196
+ ms: Date.now() - started,
197
+ });
198
+ if (envelope.status !== "SUCCESS") {
199
+ lastError =
200
+ envelope.error ?? `agy reported status ${envelope.status ?? "unknown"}: ${stdout.slice(0, 300)}`;
201
+ if (isNonRetryableAgyFailure(lastError)) break;
202
+ continue;
203
+ }
204
+ try {
205
+ // Prefer the server-parsed object, but still validate it — schema
206
+ // enforcement on their side is not a contract on ours.
207
+ if (envelope.structuredOutput !== undefined) {
208
+ return req.schema.parse(envelope.structuredOutput);
209
+ }
210
+ return req.schema.parse(JSON.parse(extractJsonObject(envelope.response ?? stdout)));
211
+ } catch (err) {
212
+ lastError = err instanceof Error ? err.message : String(err);
213
+ }
214
+ }
215
+ throw new Error(
216
+ `agy CLI ('${this.bin}') did not produce valid ${req.schemaName} JSON: ${lastError.slice(0, 400)}\n` +
217
+ `Is Antigravity installed and logged in? (https://antigravity.google — run 'agy' once interactively to sign in)`,
218
+ );
219
+ }
220
+ }
@@ -2,6 +2,7 @@ import type { Transcript } from "../schema";
2
2
  import type { Scene, SceneComponentId } from "../scene-schema";
3
3
  import type { LlmProvider, ProviderName } from "./provider";
4
4
  import { AnthropicProvider, DEFAULT_CLAUDE_MODEL } from "./anthropic";
5
+ import { AntigravityProvider } from "./antigravity";
5
6
  import { ClaudeCliProvider } from "./claude-cli";
6
7
  import { GeminiProvider, DEFAULT_GEMINI_MODEL } from "./gemini";
7
8
  import { MockProvider } from "./mock";
@@ -27,6 +28,7 @@ export * from "./beats";
27
28
  export * from "./scene-props";
28
29
  export * from "./repair";
29
30
  export { AnthropicProvider, DEFAULT_CLAUDE_MODEL } from "./anthropic";
31
+ export { AntigravityProvider } from "./antigravity";
30
32
  export { ClaudeCliProvider } from "./claude-cli";
31
33
  export { GeminiProvider, DEFAULT_GEMINI_MODEL } from "./gemini";
32
34
  export { MockProvider } from "./mock";
@@ -41,6 +43,11 @@ export function createProvider(name: ProviderName, model?: string): LlmProvider
41
43
  return new ClaudeCliProvider(model);
42
44
  case "gemini":
43
45
  return new GeminiProvider(model ?? DEFAULT_GEMINI_MODEL);
46
+ case "antigravity":
47
+ // Rides agy's cached subscription sign-in. No default model on purpose:
48
+ // the editorial tier runs whatever the user configured agy itself to
49
+ // use (FINDINGS §132, antigravity provider).
50
+ return new AntigravityProvider(model);
44
51
  case "mock":
45
52
  return new MockProvider();
46
53
  }
@@ -57,6 +64,11 @@ export const DEFAULT_FAST_MODEL: Partial<Record<ProviderName, string>> = {
57
64
  claude: "claude-haiku-4-5-20251001",
58
65
  "claude-cli": "claude-haiku-4-5-20251001",
59
66
  gemini: "gemini-3.5-flash-lite",
67
+ // Substring-matches the `gemini-3.6-flash` pricing family, so mechanical
68
+ // calls price from the existing table with no changes; the editorial tier
69
+ // (agy's own default, reported as "antigravity-default") matches nothing and
70
+ // reports "cost unknown" — the house honesty rule, not an oversight.
71
+ antigravity: "gemini-3.6-flash-low",
60
72
  };
61
73
 
62
74
  export interface TieringOptions {
@@ -87,17 +99,33 @@ export function createTieredProvider(
87
99
  /**
88
100
  * Default provider when --llm isn't given, in preference order.
89
101
  *
90
- * Gemini leads on measured evidence, not vendor preference: on the same clip
91
- * it ran 3,540 input tokens against the Claude CLI's 83,378 — the CLI re-sends
92
- * its whole harness prefix per invocation — for ~$0.05 against ~$0.85 and 27s
93
- * against 171s, with editorial output that held up. Both models recovered the
94
- * mishearing that matters ("coach and" → "code churn"); Claude is stronger only
95
- * at recovering a mangled PROPER NOUN, which `--speaker` addresses directly.
102
+ * Subscription CLIs beat ambient env keys (2026-08 decision, FINDINGS §132,
103
+ * antigravity provider): a logged-in `agy` or `claude` is an explicit,
104
+ * already-paid choice the user made on this machine, while an API key in the
105
+ * environment may just be lying around — and picking the key spends real
106
+ * per-token money the subscription would have covered. agy carries the same
107
+ * ~24k-token harness baseline per call that claude-cli does, but it is
108
+ * subscription-covered, so the weight costs nothing.
96
109
  *
97
- * Falling back to the Claude Code CLI last keeps the no-keys-configured path
98
- * working on a Pro/Max subscription rather than failing.
110
+ * Among the keys, Gemini leads on measured evidence, not vendor preference: on
111
+ * the same clip it ran 3,540 input tokens against the Claude CLI's 83,378 —
112
+ * the CLI re-sends its whole harness prefix per invocation — for ~$0.05
113
+ * against ~$0.85 and 27s against 171s, with editorial output that held up.
114
+ * Both models recovered the mishearing that matters ("coach and" → "code
115
+ * churn"); Claude is stronger only at recovering a mangled PROPER NOUN, which
116
+ * `--speaker` addresses directly.
117
+ *
118
+ * Falling back to the Claude Code CLI last keeps the nothing-configured path
119
+ * failing with install guidance rather than silence. The `hasBin` default of
120
+ * `() => false` keeps this pure — callers that can see a filesystem (the CLI)
121
+ * inject a real checker; everyone else gets the key-order behavior unchanged.
99
122
  */
100
- export function defaultProviderName(env: NodeJS.ProcessEnv = process.env): ProviderName {
123
+ export function defaultProviderName(
124
+ env: NodeJS.ProcessEnv = process.env,
125
+ hasBin: (bin: string) => boolean = () => false,
126
+ ): ProviderName {
127
+ if (hasBin(env.OSSCLIP_AGY_BIN ?? "agy")) return "antigravity";
128
+ if (hasBin(env.OSSCLIP_CLAUDE_BIN ?? "claude")) return "claude-cli";
101
129
  if (env.GEMINI_API_KEY) return "gemini";
102
130
  if (env.ANTHROPIC_API_KEY) return "claude";
103
131
  return "claude-cli";
@@ -39,4 +39,4 @@ export interface LlmProvider {
39
39
  }): Promise<T>;
40
40
  }
41
41
 
42
- export type ProviderName = "claude" | "claude-cli" | "gemini" | "mock";
42
+ export type ProviderName = "claude" | "claude-cli" | "gemini" | "antigravity" | "mock";
package/src/retake.ts CHANGED
@@ -223,13 +223,18 @@ function silenceFraction(silences: readonly Span[], start: number, end: number):
223
223
  * sits earlier is never cut, so a spurious split ends at a report line,
224
224
  * not a shear through live audio.
225
225
  */
226
+ /** An Instance plus whether the restart split fragmented its coarse sentence. */
227
+ interface SentenceInstance extends Instance {
228
+ wasSplit: boolean;
229
+ }
230
+
226
231
  function buildInstances(
227
232
  transcript: Transcript,
228
233
  analysis: Pick<Analysis, "silences" | "fillers">,
229
234
  transparentMarker?: string,
230
- ): Instance[] {
235
+ ): { fragments: Instance[]; sentences: SentenceInstance[] } {
231
236
  const words = transcript.words;
232
- if (words.length === 0) return [];
237
+ if (words.length === 0) return { fragments: [], sentences: [] };
233
238
  const fillerIndices = new Set(analysis.fillers.map((f) => f.wordIndex));
234
239
  const marker = transparentMarker ? normalizeToken(transparentMarker) : undefined;
235
240
 
@@ -269,6 +274,7 @@ function buildInstances(
269
274
  };
270
275
 
271
276
  const instances: Instance[] = [];
277
+ const sentences: SentenceInstance[] = [];
272
278
  for (const sent of coarse) {
273
279
  // A sentence that is ALREADY silence-dominated end-to-end is the
274
280
  // hallucination shape, not the restart shape: whisper sprinkles sparse
@@ -334,8 +340,17 @@ function buildInstances(
334
340
  // runs out of words mid-sentence is a trailing abandoned partial, not a
335
341
  // finished take, whatever fragment boundary it happens to land on.
336
342
  instances.push(toInstance(fragStart, sent.end, isSentenceEnd(transcript, sent.end), true));
343
+ // The whole coarse sentence, rejoined across its restart splits, for the
344
+ // §135 sentence-level pass: VO-paced delivery pauses INSIDE a sentence,
345
+ // so both takes of a genuine retake fragment into pieces that never
346
+ // match each other. `wasSplit` records whether the split actually fired —
347
+ // the pass only acts where fragmentation could have hidden a match.
348
+ sentences.push({
349
+ ...toInstance(sent.start, sent.end, isSentenceEnd(transcript, sent.end), true),
350
+ wasSplit: splitAfter.length > 0,
351
+ });
337
352
  }
338
- return instances;
353
+ return { fragments: instances, sentences };
339
354
  }
340
355
 
341
356
  // ---- matching -------------------------------------------------------------
@@ -427,11 +442,21 @@ function toPublic(i: Instance): RetakeInstance {
427
442
  * a clause boundary, not an abandoned take: its own sentence continues
428
443
  * without it, so it goes to `undecided` (report-only), never `cuts`.
429
444
  */
430
- function buildGroup(chain: readonly Instance[], hallucinated: readonly Instance[]): RetakeGroup {
445
+ function buildGroup(
446
+ chain: readonly Instance[],
447
+ hallucinated: readonly Instance[],
448
+ // The fragment pass keeps its strict bar. The §135 sentence pass gates
449
+ // survivors at the HALLUCINATION bar instead: a whole sentence carrying
450
+ // deliberate mid-sentence pauses is ordinary read-aloud delivery — the
451
+ // field run's kept take sat at 36% dead air, over the fragment bar by one
452
+ // point, and a textbook retake went report-only for it. Below the
453
+ // hallucination bar, a sentence-level survivor is a real take.
454
+ survivorBar: number = RESTART_SPLIT_MIN_SIL,
455
+ ): RetakeGroup {
431
456
  const completes = chain.filter((i) => i.complete);
432
457
  const lastComplete = completes[completes.length - 1];
433
458
  const kept =
434
- lastComplete !== undefined && lastComplete.silenceFrac <= RESTART_SPLIT_MIN_SIL
459
+ lastComplete !== undefined && lastComplete.silenceFrac <= survivorBar
435
460
  ? lastComplete
436
461
  : undefined;
437
462
  const hallu: RetakeHallucination[] = hallucinated.map((i) => ({
@@ -511,7 +536,65 @@ export function findRetakeGroups(
511
536
  analysis: Pick<Analysis, "silences" | "fillers">,
512
537
  opts: { transparentMarker?: string } = {},
513
538
  ): RetakeGroup[] {
514
- const instances = buildInstances(transcript, analysis, opts.transparentMarker);
539
+ const { fragments, sentences } = buildInstances(transcript, analysis, opts.transparentMarker);
540
+ const groups = chainGroups(fragments);
541
+
542
+ // §135 sentence-level pass — the VO field case. Deliberate mid-sentence
543
+ // pauses (read-aloud delivery) make the restart split shred BOTH takes of a
544
+ // genuine retake into fragments that never match each other, and the
545
+ // fragment pass above reports nothing ("takes half [3s] a second" vs
546
+ // "…half a millisecond" — whole-sentence similarity 0.875, zero fragment
547
+ // matches). This pass re-runs the SAME chain algorithm over whole coarse
548
+ // sentences, then keeps a group only when:
549
+ // (a) some member's sentence actually WAS split — unsplit sentences were
550
+ // already compared whole by the fragment pass, so acting on them here
551
+ // could only second-guess a decision that pass already made; and
552
+ // (b) no member's words are claimed by a fragment-pass group — including
553
+ // its `undecided` members, so a cut the abandonment/cut-validation
554
+ // rules (§128) deliberately DECLINED stays declined.
555
+ // C1-style parallel rhetoric is safe here by construction: those are
556
+ // different sentences, and whole-sentence similarity scores the divergence
557
+ // the fragment prefixes hid.
558
+ const claimed = new Set<number>();
559
+ for (const g of groups) {
560
+ const members = [g.kept, ...g.cuts, ...g.hallucinated, ...g.undecided];
561
+ for (const m of members) {
562
+ if (!m) continue;
563
+ for (let w = m.startWord; w <= m.endWord; w++) claimed.add(w);
564
+ }
565
+ }
566
+ const splitByStartWord = new Map(sentences.map((s) => [s.startWord, s.wasSplit]));
567
+ for (const g of chainGroups(sentences, HALLUCINATION_SILENCE_FRAC)) {
568
+ const members = [g.kept, ...g.cuts, ...g.hallucinated, ...g.undecided].filter(
569
+ (m): m is RetakeInstance => m !== null,
570
+ );
571
+ if (!members.some((m) => splitByStartWord.get(m.startWord))) continue;
572
+ let overlaps = false;
573
+ for (const m of members) {
574
+ for (let w = m.startWord; w <= m.endWord && !overlaps; w++) {
575
+ if (claimed.has(w)) overlaps = true;
576
+ }
577
+ }
578
+ if (overlaps) continue;
579
+ groups.push(g);
580
+ }
581
+
582
+ // Two passes can interleave in transcript order — the report reads in
583
+ // word order, whichever pass found the group.
584
+ groups.sort((a, b) => {
585
+ const first = (g: RetakeGroup): number =>
586
+ Math.min(
587
+ ...[g.kept, ...g.cuts, ...g.hallucinated, ...g.undecided]
588
+ .filter((m): m is RetakeInstance => m !== null)
589
+ .map((m) => m.startWord),
590
+ );
591
+ return first(a) - first(b);
592
+ });
593
+ return groups;
594
+ }
595
+
596
+ /** The chaining rule (doc block above), shared by both passes verbatim. */
597
+ function chainGroups(instances: readonly Instance[], survivorBar?: number): RetakeGroup[] {
515
598
  const groups: RetakeGroup[] = [];
516
599
 
517
600
  let anchor: Instance | null = null;
@@ -520,7 +603,7 @@ export function findRetakeGroups(
520
603
 
521
604
  const finalize = (): void => {
522
605
  if (chain.length >= 2 || (chain.length >= 1 && hallucinated.length > 0)) {
523
- groups.push(buildGroup(chain, hallucinated));
606
+ groups.push(buildGroup(chain, hallucinated, survivorBar));
524
607
  }
525
608
  chain = [];
526
609
  hallucinated = [];