@alexkroman1/aai-cli 13.3.0 → 15.0.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 (158) hide show
  1. package/dist/{_agent-Clbq3a4h.mjs → _agent-BzUeqOdj.mjs} +32 -9
  2. package/dist/_agent.d.ts +23 -0
  3. package/dist/_artifacts-BJOYGQPp.mjs +21 -0
  4. package/dist/_artifacts.d.ts +16 -0
  5. package/dist/_build-target.d.ts +172 -0
  6. package/dist/{_bundler-DEkjO1d8.mjs → _bundler-DM0d0M7m.mjs} +8 -7
  7. package/dist/_cli-common.d.ts +45 -6
  8. package/dist/{_config-ZY2euhdh.mjs → _config-DYzC6WMD.mjs} +2 -2
  9. package/dist/_dev-env.d.ts +1 -1
  10. package/dist/{_dev-server-C13xuGB1.mjs → _dev-server-BzWB6-4y.mjs} +25 -24
  11. package/dist/_dev-vite-config.d.ts +3 -3
  12. package/dist/_e2e-test-utils.d.ts +1 -1
  13. package/dist/{_init-Ci4QGjp8.mjs → _init-Bsi3DZNJ.mjs} +9 -23
  14. package/dist/{_output-DbJt9gsl.mjs → _output-DBP9Op_d.mjs} +22 -13
  15. package/dist/_output.d.ts +10 -9
  16. package/dist/{_preflight-B2laWPdU.mjs → _preflight-CcAR577V.mjs} +2 -2
  17. package/dist/_resource-commands.d.ts +9 -9
  18. package/dist/_server-common-De0haHr9.mjs +70 -0
  19. package/dist/_server-common.d.ts +20 -1
  20. package/dist/{_slug-api-DlKpj91X.mjs → _slug-api-CGvc2YSQ.mjs} +18 -9
  21. package/dist/_slug-api.d.ts +19 -0
  22. package/dist/{_studio-DTi3EdXQ.mjs → _studio-DnR_BqFp.mjs} +7 -3
  23. package/dist/_studio-commands.d.ts +13 -13
  24. package/dist/{_templates-V4l5sZJy.mjs → _templates-CIlJ3Vay.mjs} +4 -4
  25. package/dist/_templates.d.ts +1 -1
  26. package/dist/{_typecheck-gate-sV4N5F93.mjs → _typecheck-gate-U3jcil8L.mjs} +3 -2
  27. package/dist/_typecheck-gate.d.ts +3 -1
  28. package/dist/{_utils-Dhd058D9.mjs → _utils-D5JGcjiW.mjs} +43 -1
  29. package/dist/_utils.d.ts +35 -0
  30. package/dist/_vercel-output.d.ts +63 -0
  31. package/dist/{_workflow-determinism-C9l8uC2R.mjs → _workflow-determinism-CvpqMpdh.mjs} +18 -26
  32. package/dist/_workflow-determinism.d.ts +3 -3
  33. package/dist/build-BhEaxBPu.mjs +481 -0
  34. package/dist/build.d.ts +23 -10
  35. package/dist/cli.d.ts +1 -2
  36. package/dist/cli.mjs +155 -138
  37. package/dist/{client-bundler-CT-_26c5.mjs → client-bundler-6mTLs6ny.mjs} +5 -5
  38. package/dist/client-bundler.d.ts +1 -1
  39. package/dist/client-bundler.mjs +1 -1
  40. package/dist/{delete-RqlQZ6Y_.mjs → delete-BdFnbnVR.mjs} +4 -4
  41. package/dist/{deploy-B7f3CK7v.mjs → deploy-CGqPU5U-.mjs} +11 -11
  42. package/dist/{dev-DamXieW_.mjs → dev-Bx9gYBHM.mjs} +3 -3
  43. package/dist/{eval-h3DTVfRw.mjs → eval-B3I7FqN9.mjs} +3 -3
  44. package/dist/{init-D-vFrV8h.mjs → init-CFyusRbq.mjs} +48 -20
  45. package/dist/init.d.ts +14 -0
  46. package/dist/{login-CDiemNmC.mjs → login-DaLrPOFg.mjs} +4 -6
  47. package/dist/{logs-DGwPNCzB.mjs → logs-7BRwq0cG.mjs} +21 -14
  48. package/dist/project-config.mjs +1 -1
  49. package/dist/scaffold/CLAUDE.md +155 -76
  50. package/dist/scaffold/package.json +6 -6
  51. package/dist/{secret-V1w7y9-j.mjs → secret-Cjp912yF.mjs} +5 -5
  52. package/dist/secret.d.ts +8 -6
  53. package/dist/start.d.ts +112 -0
  54. package/dist/start.mjs +156 -0
  55. package/dist/{studio-BpMCfUNx.mjs → studio-C_zuRC_z.mjs} +13 -15
  56. package/dist/templates/briefing-desk/agent.eval.test.ts +156 -0
  57. package/dist/templates/call-audit/agent.test.ts +11 -6
  58. package/dist/templates/call-audit/agent.ts +2 -2
  59. package/dist/templates/call-audit/client.tsx +27 -42
  60. package/dist/templates/call-audit/workflows/audit.ts +7 -5
  61. package/dist/templates/call-audit/workflows/ingest.ts +7 -7
  62. package/dist/templates/call-audit/workflows/media.ts +12 -37
  63. package/dist/templates/call-audit/workflows/summarize.ts +8 -8
  64. package/dist/templates/call-audit/workflows/sync-api.ts +3 -3
  65. package/dist/templates/code-interpreter/agent.test.ts +103 -0
  66. package/dist/templates/dispatch-center/agent.test.ts +7 -7
  67. package/dist/templates/dispatch-center/client.tsx +53 -61
  68. package/dist/templates/dispatch-center/shared.ts +0 -8
  69. package/dist/templates/dispatch-center/tools/ops_dashboard.ts +4 -1
  70. package/dist/templates/embedded-assets/agent.eval.test.ts +4 -2
  71. package/dist/templates/health-assistant/agent.eval.test.ts +4 -2
  72. package/dist/templates/infocom-adventure/client.tsx +40 -36
  73. package/dist/templates/link-digest/agent.test.ts +3 -3
  74. package/dist/templates/link-digest/agent.ts +2 -2
  75. package/dist/templates/link-digest/client.tsx +81 -19
  76. package/dist/templates/link-digest/workflows/digest.ts +13 -16
  77. package/dist/templates/math-buddy/agent.test.ts +126 -0
  78. package/dist/templates/night-owl/agent.eval.test.ts +4 -2
  79. package/dist/templates/night-owl/client.tsx +8 -2
  80. package/dist/templates/personal-finance/agent.test.ts +127 -0
  81. package/dist/templates/pipeline-simple/agent.test.ts +2 -2
  82. package/dist/templates/pizza-ordering/client.tsx +5 -4
  83. package/dist/templates/pizza-ordering/shared.ts +3 -8
  84. package/dist/templates/pizza-ordering/tools/add_pizza.ts +3 -2
  85. package/dist/templates/pizza-ordering/tools/place_order.ts +3 -2
  86. package/dist/templates/pizza-ordering/tools/remove_pizza.ts +3 -2
  87. package/dist/templates/pizza-ordering/tools/update_pizza.ts +3 -2
  88. package/dist/templates/pizza-ordering/tools/view_order.ts +3 -2
  89. package/dist/templates/plan-and-execute/agent.test.ts +10 -5
  90. package/dist/templates/plan-and-execute/client.tsx +2 -2
  91. package/dist/templates/podcast-digest/agent.test.ts +20 -11
  92. package/dist/templates/podcast-digest/client.tsx +16 -22
  93. package/dist/templates/podcast-digest/workflows/digest.ts +21 -19
  94. package/dist/templates/podcast-digest/workflows/feeds.ts +61 -33
  95. package/dist/templates/podcast-digest/workflows/slack.ts +7 -7
  96. package/dist/templates/recap-workflow/agent.eval.test.ts +21 -13
  97. package/dist/templates/recap-workflow/agent.test.ts +27 -23
  98. package/dist/templates/recap-workflow/workflows/recap.ts +24 -28
  99. package/dist/templates/redline/agent.eval.test.ts +5 -8
  100. package/dist/templates/redline/agent.test.ts +1 -1
  101. package/dist/templates/redline/client.tsx +36 -34
  102. package/dist/templates/redline/workflows/redline.ts +17 -17
  103. package/dist/templates/research-workflow/agent.eval.test.ts +5 -5
  104. package/dist/templates/research-workflow/agent.test.ts +10 -7
  105. package/dist/templates/research-workflow/workflows/research.ts +28 -30
  106. package/dist/templates/retail/agent.test.ts +17 -15
  107. package/dist/templates/retail/cancel.ts +7 -6
  108. package/dist/templates/retail/client.tsx +39 -58
  109. package/dist/templates/retail/payment.ts +4 -3
  110. package/dist/templates/retail/pending.test.ts +19 -19
  111. package/dist/templates/retail/store.test.ts +5 -5
  112. package/dist/templates/retail/swap.ts +8 -11
  113. package/dist/templates/solo-rpg/agent.eval.test.ts +2 -16
  114. package/dist/templates/solo-rpg/agent.test.ts +12 -12
  115. package/dist/templates/solo-rpg/client.tsx +3 -3
  116. package/dist/templates/spoken-summary/agent.test.ts +9 -9
  117. package/dist/templates/spoken-summary/agent.ts +3 -3
  118. package/dist/templates/spoken-summary/client.tsx +23 -30
  119. package/dist/templates/spoken-summary/workflows/summarize.ts +11 -11
  120. package/dist/templates/spoken-summary/workflows/transcribe.ts +14 -14
  121. package/dist/templates/support-line/agent.ts +8 -0
  122. package/dist/templates/support-line/client.tsx +4 -3
  123. package/dist/templates/support-line/prompts.ts +0 -2
  124. package/dist/templates/transcription-workflow/agent.test.ts +20 -12
  125. package/dist/templates/transcription-workflow/agent.ts +2 -2
  126. package/dist/templates/transcription-workflow/client.tsx +15 -14
  127. package/dist/templates/transcription-workflow/recover.ts +1 -1
  128. package/dist/templates/transcription-workflow/run-panel.tsx +34 -14
  129. package/dist/templates/transcription-workflow/total-latency.tsx +46 -20
  130. package/dist/templates/transcription-workflow/workflows/batch.ts +15 -15
  131. package/dist/templates/transcription-workflow/workflows/downsample.ts +1 -1
  132. package/dist/templates/transcription-workflow/workflows/normalize.ts +15 -19
  133. package/dist/templates/transcription-workflow/workflows/stream.ts +12 -12
  134. package/dist/templates/transcription-workflow/workflows/sync-api.ts +3 -3
  135. package/dist/templates/transcription-workflow/workflows/transcribe.ts +23 -34
  136. package/dist/templates/travel-concierge/agent.eval.test.ts +2 -2
  137. package/dist/templates/travel-concierge/agent.test.ts +15 -7
  138. package/dist/templates/travel-concierge/client.tsx +2 -2
  139. package/dist/templates/travel-concierge/routing.ts +64 -55
  140. package/dist/templates/travel-concierge/shared.ts +10 -14
  141. package/dist/templates/travel-concierge/tools/cancel_action.ts +3 -1
  142. package/dist/templates/travel-concierge/tools/complete_or_escalate.ts +3 -1
  143. package/dist/templates/travel-concierge/tools/confirm_action.ts +3 -1
  144. package/dist/templates/travel-concierge/tools/lookup_booking.ts +4 -3
  145. package/dist/templates/travel-concierge/tools/search_car_rentals.ts +3 -2
  146. package/dist/templates/travel-concierge/tools/search_excursions.ts +3 -2
  147. package/dist/templates/travel-concierge/tools/search_flights.ts +3 -2
  148. package/dist/templates/travel-concierge/tools/search_hotels.ts +3 -2
  149. package/dist/templates/web-researcher/agent.test.ts +130 -0
  150. package/dist/{test-guenJUxi.mjs → test-D032V2cA.mjs} +4 -17
  151. package/dist/typecheck.mjs +1 -1
  152. package/dist/worker-bundler.d.ts +1 -1
  153. package/dist/worker-bundler.mjs +8 -8
  154. package/dist/{workflow-BZyKLkhV.mjs → workflow-CVioyO3V.mjs} +6 -5
  155. package/package.json +9 -4
  156. package/dist/_server-common-BV4_CrP3.mjs +0 -43
  157. package/dist/build-CWyFGe-W.mjs +0 -69
  158. package/dist/scaffold/server.mjs +0 -204
@@ -21,6 +21,7 @@
21
21
  import { countWords, formatDuration, plural } from "@alexkroman1/aai/utils";
22
22
  import type { WorkflowOutputOf } from "@alexkroman1/aai/workflow-api";
23
23
  import {
24
+ Facts,
24
25
  isTerminal,
25
26
  useWorkflowProgress,
26
27
  WORKFLOW_STATUS_LABELS,
@@ -136,7 +137,7 @@ export function RunPanel({ run, onClear }: { run: WorkflowRun<Transcript>; onCle
136
137
  above, and the reason both exist: the status is `running` for the whole
137
138
  fan-out, so a sixty-segment recording and a one-segment recording look
138
139
  identical while they run. These lines come from the run itself
139
- (`report()` in `workflows/transcribe.ts`), and they REPLAY, so looking a
140
+ (`stepReport()` in `workflows/transcribe.ts`), and they REPLAY, so looking a
140
141
  finished run up in the panel below shows how it got there. */}
141
142
  <WorkflowProgress runId={run.runId} />
142
143
 
@@ -150,15 +151,25 @@ export function RunPanel({ run, onClear }: { run: WorkflowRun<Transcript>; onCle
150
151
  object with optional fields. */}
151
152
  {run.status === "completed" && (
152
153
  <>
153
- <p className="text-xs opacity-60">
154
- {run.output.segments} {plural(run.output.segments, "segment")} ·{" "}
155
- {formatDuration(run.output.durationMs)} of audio · took{" "}
156
- {formatDuration(run.output.elapsedMs)} · {run.output.words} words
157
- </p>
154
+ <Facts
155
+ size="xs"
156
+ items={[
157
+ `${run.output.segments} ${plural(run.output.segments, "segment")}`,
158
+ `${formatDuration(run.output.durationMs)} of audio`,
159
+ `took ${formatDuration(run.output.elapsedMs)}`,
160
+ `${run.output.words} words`,
161
+ ]}
162
+ />
158
163
  <pre className="whitespace-pre-wrap text-sm leading-relaxed">{run.output.transcript}</pre>
159
164
  </>
160
165
  )}
161
- {run.status === "failed" && <p className="text-red-600">{run.error}</p>}
166
+ {/* `role="alert"`, the same contract `<Form>` gives the submit error in
167
+ `client.tsx`: this is the outcome the reader waited minutes for. */}
168
+ {run.status === "failed" && (
169
+ <p role="alert" className="text-red-600">
170
+ {run.error}
171
+ </p>
172
+ )}
162
173
  </section>
163
174
  );
164
175
  }
@@ -186,17 +197,26 @@ function LiveTranscript({ runId }: { runId: string }) {
186
197
  // Memoized on the ARRAY, which the hook appends to per read: stitching is a
187
198
  // seam search per segment, and a fan-out re-renders this panel on every
188
199
  // progress poll whether or not anything arrived.
189
- const transcript = useMemo(() => stitchChunks(progress), [progress]);
200
+ //
201
+ // The word count and the coverage are derived in the SAME memo, not below
202
+ // it: both are functions of `progress` alone, and `countWords` rescans a
203
+ // transcript that reaches ~14k words on a feature-length recording.
204
+ //
205
+ // `covered` is the furthest point reached, not the count: segments land out
206
+ // of order, so "6 segments" says nothing about how much is covered.
207
+ const { transcript, words, covered } = useMemo(() => {
208
+ const stitched = stitchChunks(progress);
209
+ return {
210
+ transcript: stitched,
211
+ words: countWords(stitched),
212
+ covered: progress.reduce((furthest, chunk) => Math.max(furthest, chunk.endMs), 0),
213
+ };
214
+ }, [progress]);
190
215
  if (progress.length === 0) return null;
191
216
 
192
- // The furthest point reached, not the count: segments land out of order, so
193
- // "6 segments" says nothing about how much of the recording is covered.
194
- const covered = Math.max(...progress.map((chunk) => chunk.endMs));
195
217
  return (
196
218
  <div className="flex flex-col gap-2">
197
- <p className="text-xs opacity-60">
198
- {countWords(transcript)} words so far · through {formatDuration(covered)}
199
- </p>
219
+ <Facts size="xs" items={[`${words} words so far`, `through ${formatDuration(covered)}`]} />
200
220
  <pre className="whitespace-pre-wrap text-sm leading-relaxed opacity-80">{transcript}</pre>
201
221
  </div>
202
222
  );
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  import { formatDuration } from "@alexkroman1/aai/utils";
18
+ import { Facts } from "@alexkroman1/aai-ui";
18
19
  import { useCallback, useEffect, useRef, useState } from "react";
19
20
 
20
21
  /**
@@ -29,12 +30,17 @@ const STOPWATCH_TICK_MS = 250;
29
30
  /** What {@link useTotalLatency} reports. */
30
31
  export type TotalLatency = {
31
32
  /**
32
- * Milliseconds since the submit ticking while the submission is in flight,
33
- * frozen at the finish, and undefined before the first one.
33
+ * The mark the clock started at, or undefined before the first submit.
34
+ *
35
+ * The raw mark rather than an elapsed duration, and that is the load-bearing
36
+ * part: an elapsed number is stale the instant it is computed, so a page
37
+ * holding one has to re-render to keep it true — which is what used to drive
38
+ * the whole tree at 4 Hz. {@link TotalLatency} turns these two marks into a
39
+ * duration, and only that section re-renders.
34
40
  */
35
- elapsedMs: number | undefined;
36
- /** Whether the clock is still running, which is what makes the label honest. */
37
- running: boolean;
41
+ startedAt: number | undefined;
42
+ /** The elapsed time at the moment the run settled, once it has. */
43
+ frozenMs: number | undefined;
38
44
  /** Start (or restart) the clock. Called from the form's own submit handler. */
39
45
  start: () => void;
40
46
  /** Drop it, for a panel that no longer describes the submission it timed. */
@@ -53,7 +59,10 @@ export type TotalLatency = {
53
59
  *
54
60
  * - **The interval re-renders; it does not accumulate.** The elapsed time is read
55
61
  * from the clock at render, so a tick the tab throttled or dropped cannot make
56
- * the number lag behind real time.
62
+ * the number lag behind real time. The interval lives in {@link TotalLatency}
63
+ * rather than here, because a hook called from the page makes its ticks the
64
+ * PAGE's re-renders — the form, the upload bar, the run panel and the whole
65
+ * history list, four times a second, to move one duration string.
57
66
  * - **`performance.now()`, not `Date.now()`.** It is monotonic, so a clock
58
67
  * correction (NTP, a laptop waking up) cannot make a transcription look
59
68
  * instant — or negative.
@@ -61,8 +70,6 @@ export type TotalLatency = {
61
70
  export function useTotalLatency(inFlight: boolean): TotalLatency {
62
71
  const [startedAt, setStartedAt] = useState<number | undefined>(undefined);
63
72
  const [frozenMs, setFrozenMs] = useState<number | undefined>(undefined);
64
- // Re-render trigger only — see the doc above.
65
- const [, tick] = useState(0);
66
73
  // Whether `inFlight` has been seen true since the last `start()`. Without it,
67
74
  // a start that lands one render before the submission reports itself in flight
68
75
  // would freeze the clock at zero instead of running it.
@@ -72,8 +79,7 @@ export function useTotalLatency(inFlight: boolean): TotalLatency {
72
79
  if (startedAt === undefined || frozenMs !== undefined) return;
73
80
  if (inFlight) {
74
81
  began.current = true;
75
- const id = setInterval(() => tick((n) => n + 1), STOPWATCH_TICK_MS);
76
- return () => clearInterval(id);
82
+ return;
77
83
  }
78
84
  // Measured here rather than at render, so the frozen number is the one at the
79
85
  // moment the run settled rather than whenever this page next drew.
@@ -93,8 +99,8 @@ export function useTotalLatency(inFlight: boolean): TotalLatency {
93
99
  }, []);
94
100
 
95
101
  return {
96
- elapsedMs: frozenMs ?? (startedAt === undefined ? undefined : performance.now() - startedAt),
97
- running: startedAt !== undefined && frozenMs === undefined,
102
+ startedAt,
103
+ frozenMs,
98
104
  start,
99
105
  clear,
100
106
  };
@@ -114,17 +120,31 @@ export function useTotalLatency(inFlight: boolean): TotalLatency {
114
120
  * notices it finished. Clamped at zero, because the two numbers come from two
115
121
  * different clocks on two different machines and a few milliseconds the wrong way
116
122
  * would otherwise print a negative.
123
+ *
124
+ * The stopwatch interval lives HERE rather than in {@link useTotalLatency}, so a
125
+ * running clock re-renders this section and nothing else.
117
126
  */
118
127
  export function TotalLatency({
119
- elapsedMs,
120
- running,
128
+ startedAt,
129
+ frozenMs,
121
130
  runMs,
122
131
  }: {
123
- elapsedMs: number | undefined;
124
- running: boolean;
132
+ startedAt: number | undefined;
133
+ frozenMs: number | undefined;
125
134
  runMs: number | undefined;
126
135
  }) {
127
- if (elapsedMs === undefined) return null;
136
+ // Re-render trigger only: the elapsed time is read from the clock below, so a
137
+ // tick the tab throttled or dropped cannot make the number lag real time.
138
+ const [, tick] = useState(0);
139
+ const running = startedAt !== undefined && frozenMs === undefined;
140
+ useEffect(() => {
141
+ if (!running) return;
142
+ const id = setInterval(() => tick((n) => n + 1), STOPWATCH_TICK_MS);
143
+ return () => clearInterval(id);
144
+ }, [running]);
145
+
146
+ if (startedAt === undefined) return null;
147
+ const elapsedMs = frozenMs ?? performance.now() - startedAt;
128
148
  const outside = runMs === undefined ? undefined : Math.max(0, elapsedMs - runMs);
129
149
  return (
130
150
  <section className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 rounded-md border px-5 py-3">
@@ -134,9 +154,15 @@ export function TotalLatency({
134
154
  <span className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
135
155
  <span className="text-sm tabular-nums">{formatDuration(elapsedMs)}</span>
136
156
  {runMs !== undefined && outside !== undefined && (
137
- <span className="text-xs tabular-nums opacity-60">
138
- {formatDuration(outside)} before the run · {formatDuration(runMs)} inside it
139
- </span>
157
+ <Facts
158
+ size="xs"
159
+ as="span"
160
+ className="tabular-nums"
161
+ items={[
162
+ `${formatDuration(outside)} before the run`,
163
+ `${formatDuration(runMs)} inside it`,
164
+ ]}
165
+ />
140
166
  )}
141
167
  </span>
142
168
  </section>
@@ -53,12 +53,12 @@
53
53
  * request rather than two and the value journaled by the last poll IS the result.
54
54
  */
55
55
 
56
- import type { WorkflowCtx } from "@alexkroman1/aai";
57
- import { report, TRANSCRIBE_API, uploadInfo } from "@alexkroman1/aai/step";
56
+ import type { WorkflowContext } from "@alexkroman1/aai";
57
+ import { stepReport, stepUploadInfo, TRANSCRIBE_API } from "@alexkroman1/aai/step";
58
58
  import {
59
- stepTranscribePollClassified,
60
- stepTranscribeSubmitClassified,
61
- stepTranscribeUploadClassified,
59
+ stepTranscribePollOrFail,
60
+ stepTranscribeSubmitOrFail,
61
+ stepTranscribeUploadOrFail,
62
62
  } from "@alexkroman1/aai/step-errors";
63
63
  import { countWords, formatBytes } from "@alexkroman1/aai/utils";
64
64
  import type { Transcript } from "./transcribe.ts";
@@ -78,7 +78,7 @@ const MAX_POLLS = 360;
78
78
  /** Transcribe a recording through the async API. */
79
79
  export async function transcribeBatchFlow(
80
80
  input: { recording: string },
81
- ctx: WorkflowCtx,
81
+ ctx: WorkflowContext,
82
82
  ): Promise<Transcript> {
83
83
  // Both at once: the clock does not depend on the upload, and issuing them
84
84
  // together costs one round trip instead of two before a byte moves. Their issue
@@ -122,24 +122,24 @@ export async function transcribeBatchFlow(
122
122
  * far smaller: if it expires before the next step runs, the run fails and a fresh one
123
123
  * re-uploads — which is what would have happened anyway, once, instead of five times.
124
124
  *
125
- * The `Classified` callers on `@alexkroman1/aai/step-errors` are the SDK's own
125
+ * The `OrFail` callers on `@alexkroman1/aai/step-errors` are the SDK's own
126
126
  * `stepTranscribe*` plus `throwStepError` and nothing else, which is what turns the
127
127
  * SDK's `TranscribeError` into the DevKit's verdict: a missing key and a 400 stop, a
128
128
  * 429 waits as long as the service asked. Every step here ends the same way for the
129
129
  * same reason.
130
130
  */
131
131
  export async function uploadToProvider(uploadId: string): Promise<{ audioUrl: string }> {
132
- const stored = await uploadInfo(uploadId);
133
- await report(
132
+ const stored = await stepUploadInfo(uploadId);
133
+ await stepReport(
134
134
  `Uploading ${stored.name || uploadId} (${formatBytes(stored.size)}) to the async API.`,
135
135
  );
136
- return await stepTranscribeUploadClassified(uploadId);
136
+ return await stepTranscribeUploadOrFail(uploadId);
137
137
  }
138
138
 
139
139
  /** Create the transcription job, and answer with the id that outlives this run. */
140
140
  export async function createJob(audioUrl: string): Promise<{ id: string }> {
141
- const job = await stepTranscribeSubmitClassified(audioUrl);
142
- await report(`Submitted — job ${job.id}.`);
141
+ const job = await stepTranscribeSubmitOrFail(audioUrl);
142
+ await stepReport(`Submitted — job ${job.id}.`);
143
143
  return job;
144
144
  }
145
145
 
@@ -156,13 +156,13 @@ export async function pollTranscript(
156
156
  id: string,
157
157
  startedAt: number,
158
158
  ): Promise<{ done: false } | { done: true; transcript: Transcript }> {
159
- const progress = await stepTranscribePollClassified(id);
159
+ const progress = await stepTranscribePollOrFail(id);
160
160
  if (!progress.done) {
161
- await report(`Transcript ${id} is ${progress.status}.`);
161
+ await stepReport(`Transcript ${id} is ${progress.status}.`);
162
162
  return { done: false };
163
163
  }
164
164
 
165
- const stored = await uploadInfo(uploadId);
165
+ const stored = await stepUploadInfo(uploadId);
166
166
  const transcript = progress.transcript.text;
167
167
  return {
168
168
  done: true,
@@ -152,7 +152,7 @@ export function downsampleSegment(
152
152
  const bind = sampleReaderFor(from);
153
153
  const frame = blockAlign(from);
154
154
  const inFrames = Math.floor(bytes.length / frame);
155
- // A CALLER ERROR, and stated as one. `readUpload` clamps a window to what is
155
+ // A CALLER ERROR, and stated as one. `stepReadUpload` clamps a window to what is
156
156
  // stored, so the streaming flow can hand a short read down here; with no whole
157
157
  // frame the averaging window below is zero wide, `total / 0` is `NaN`, and
158
158
  // `setInt16` writes that as a 0 — a two-byte WAV of silence, transcribed and
@@ -70,8 +70,8 @@
70
70
  */
71
71
 
72
72
  import { basename, extname, join } from "node:path";
73
- import { probeMedia, runFfmpeg, wavEncodeArgs } from "@alexkroman1/aai/ffmpeg";
74
- import { readUpload, report, requireCompleteUpload } from "@alexkroman1/aai/step";
73
+ import { ffmpegBaseArgs, probeMedia, runFfmpeg, wavEncodeArgs } from "@alexkroman1/aai/ffmpeg";
74
+ import { stepReadUpload, stepReport, stepRequireCompleteUpload } from "@alexkroman1/aai/step";
75
75
  import { throwFfmpegStepError } from "@alexkroman1/aai/step-errors";
76
76
  import { readUploadToFile, withTempDir, writeUploadFromFile } from "@alexkroman1/aai/step-files";
77
77
  import { formatBytes, formatDuration } from "@alexkroman1/aai/utils";
@@ -122,16 +122,16 @@ export type NormalizedRecording = {
122
122
  * file that already exists instead of paying for a second one.
123
123
  */
124
124
  export async function normalizeRecording(uploadId: string): Promise<NormalizedRecording> {
125
- // `requireCompleteUpload`, not `uploadInfo`: `size` is the readable PREFIX, and
125
+ // `stepRequireCompleteUpload`, not `stepUploadInfo`: `size` is the readable PREFIX, and
126
126
  // every judgement below — cuttable, heavier-per-second, the byte count copied to
127
127
  // disk — is about the WHOLE file.
128
- const stored = await requireCompleteUpload(uploadId);
129
- const head = await readUpload(uploadId, { end: HEADER_PROBE_BYTES });
128
+ const stored = await stepRequireCompleteUpload(uploadId);
129
+ const head = await stepReadUpload(uploadId, { end: HEADER_PROBE_BYTES });
130
130
 
131
131
  if (cuttable(head.bytes, stored.size) && !heavierThanNormalized(head.bytes, stored.size)) {
132
132
  // No subprocess, no copy, no second upload. The overwhelmingly common case
133
133
  // for a desk whose form says WAV, and the reason the check is a 64 KB read.
134
- await report(`${stored.name || uploadId} is already linear-PCM WAV — cutting it as it is.`);
134
+ await stepReport(`${stored.name || uploadId} is already linear-PCM WAV — cutting it as it is.`);
135
135
  return { recording: uploadId, converted: false };
136
136
  }
137
137
 
@@ -144,7 +144,7 @@ export async function normalizeRecording(uploadId: string): Promise<NormalizedRe
144
144
  // WAV that is fine but too heavy to cut at this rate. Reporting "not a WAV we
145
145
  // can cut" for the second one is a line that contradicts the file they
146
146
  // uploaded.
147
- await report(
147
+ await stepReport(
148
148
  `Converting ${stored.name || uploadId} (${formatBytes(stored.size)}) — ` +
149
149
  (cuttable(head.bytes, stored.size)
150
150
  ? `heavier per second than ${NORMALIZED_SAMPLE_RATE / 1000} kHz mono.`
@@ -164,7 +164,7 @@ export async function normalizeRecording(uploadId: string): Promise<NormalizedRe
164
164
  // `STEP_FILE_READ_CONCURRENCY` of them. Passing `size` means "I am judging
165
165
  // completeness myself", which is what a body polling a still-arriving
166
166
  // upload needs and is the opposite of what happened above: this step has
167
- // already called `requireCompleteUpload`, so the file IS whole and the
167
+ // already called `stepRequireCompleteUpload`, so the file IS whole and the
168
168
  // windows may land in any order. Omitting it lets `readUploadToFile`
169
169
  // establish that for itself and fan out. The cost is one metadata round
170
170
  // trip, against the dozens of window reads it overlaps.
@@ -177,22 +177,18 @@ export async function normalizeRecording(uploadId: string): Promise<NormalizedRe
177
177
  const info = await probeMedia(source, { timeoutMs: CONVERT_TIMEOUT_MS }).catch(
178
178
  throwFfmpegStepError,
179
179
  );
180
- await report(
180
+ await stepReport(
181
181
  `It is ${describeSource(info.audio?.codec, info.durationSec)} — re-encoding to ` +
182
182
  `${NORMALIZED_SAMPLE_RATE / 1000} kHz mono WAV.`,
183
183
  );
184
184
 
185
185
  await runFfmpeg(
186
186
  [
187
- // The argv is the caller's, verbatim — `runFfmpeg` adds nothing. So the
188
- // standing flags are here: quiet, non-interactive, overwrite. `-nostdin`
189
- // matters most in a guest, where there is no terminal and an ffmpeg that
190
- // decides to read stdin is a process that never exits.
191
- "-hide_banner",
192
- "-loglevel",
193
- "error",
194
- "-nostdin",
195
- "-y",
187
+ // The argv is the caller's, verbatim — `runFfmpeg` adds nothing so
188
+ // the standing flags are the caller's too. This copy used to omit
189
+ // `-nostats`, which is the one that keeps ffmpeg's progress spam from
190
+ // evicting the error out of the captured stderr tail.
191
+ ...ffmpegBaseArgs(),
196
192
  "-i",
197
193
  source,
198
194
  ...wavEncodeArgs({
@@ -212,7 +208,7 @@ export async function normalizeRecording(uploadId: string): Promise<NormalizedRe
212
208
  type: "audio/wav",
213
209
  });
214
210
 
215
- await report(
211
+ await stepReport(
216
212
  `Converted to ${formatBytes(written.size)} of WAV (from ${formatBytes(stored.size)}).`,
217
213
  );
218
214
  return { recording: written.id, converted: true };
@@ -22,7 +22,7 @@
22
22
  * first byte with `complete: false` and its `size` grows as bytes land.
23
23
  *
24
24
  * The reader needed almost nothing for this, which is why this flow is so close to
25
- * the other one: `readUpload` already clamped its window to what is stored (so a
25
+ * the other one: `stepReadUpload` already clamped its window to what is stored (so a
26
26
  * plan computed from a header could end one byte past the file), and that clamp is
27
27
  * exactly "read what has arrived". So `transcribeSegment` below is `transcribe.ts`'s
28
28
  * OWN step, unchanged, called on windows this body has checked are present.
@@ -50,7 +50,7 @@
50
50
  * 0.9 MB/s: `size` was 0 at every poll for 45 seconds and then the whole file, so
51
51
  * the run planned nothing, transcribed nothing, and did its entire fan-out after
52
52
  * the upload — the classic flow, with extra steps. `segmentStored` reads `ranges`
53
- * instead, and `readUpload` clamps to the run a read starts in rather than to the
53
+ * instead, and `stepReadUpload` clamps to the run a read starts in rather than to the
54
54
  * prefix, so a window that has landed is a window this flow can work on.
55
55
  * - **The stall test on the prefix would then FAIL a healthy upload.** A parts
56
56
  * upload moving at full speed reports the same prefix at every poll, which is
@@ -167,13 +167,13 @@
167
167
  * what keeps that order a pure function of journaled values.
168
168
  */
169
169
 
170
- import type { WorkflowCtx } from "@alexkroman1/aai";
170
+ import type { WorkflowContext } from "@alexkroman1/aai";
171
171
  import {
172
172
  mapConcurrent,
173
- readUpload,
174
- report,
173
+ stepReadUpload,
174
+ stepReport,
175
+ stepUploadInfo,
175
176
  type UploadRange,
176
- uploadInfo,
177
177
  } from "@alexkroman1/aai/step";
178
178
  import { throwFatalStepError } from "@alexkroman1/aai/step-errors";
179
179
  import { formatDuration, omitUndefined, plural } from "@alexkroman1/aai/utils";
@@ -308,7 +308,7 @@ export type StreamPlan = {
308
308
  * an upload id exactly as in the classic flow; what differs is that the client chose
309
309
  * it and the bytes are still on their way.
310
310
  */
311
- export async function transcribeStreamFlow(input: { recording: string }, ctx: WorkflowCtx) {
311
+ export async function transcribeStreamFlow(input: { recording: string }, ctx: WorkflowContext) {
312
312
  // `ctx.now()`, not a step: the engine journals the read under its own key, so
313
313
  // every walk of this line sees the instant the first one did.
314
314
  const startedAt = await ctx.now();
@@ -364,7 +364,7 @@ export async function transcribeStreamFlow(input: { recording: string }, ctx: Wo
364
364
  // A segment is READY when its whole window is stored — except once the upload
365
365
  // is complete, where `at.size` is the true total and the plan came from the
366
366
  // header's DECLARED length: a recording that came up short leaves a final
367
- // segment ending past the file, and `readUpload` clamping is what makes that
367
+ // segment ending past the file, and `stepReadUpload` clamping is what makes that
368
368
  // the right answer rather than an error.
369
369
  const ready = plan.segments.filter(
370
370
  (segment) =>
@@ -443,7 +443,7 @@ export async function transcribeStreamFlow(input: { recording: string }, ctx: Wo
443
443
  * from.
444
444
  */
445
445
  export async function probeUpload(id: string): Promise<UploadProgressView> {
446
- const info = await uploadInfo(id);
446
+ const info = await stepUploadInfo(id);
447
447
  return {
448
448
  size: info.size,
449
449
  complete: info.complete,
@@ -609,7 +609,7 @@ export function storedBytes(size: number, ranges: readonly UploadRange[] | undef
609
609
  * only the prefix and the run has nothing to do until the upload is over, which
610
610
  * is the entire wait this flow exists to remove.
611
611
  *
612
- * A window has to be covered WHOLE by one run: `readUpload` clamps to the run a
612
+ * A window has to be covered WHOLE by one run: `stepReadUpload` clamps to the run a
613
613
  * read starts in, so a segment straddling a hole would come back short and be
614
614
  * transcribed as a fragment. `rangesOf` merges adjacent windows, so a run really
615
615
  * is a contiguous stretch and one containment test is the whole check.
@@ -636,7 +636,7 @@ export function segmentStored(segment: Segment, at: UploadProgressView): boolean
636
636
  * which is what the classic flow is for.
637
637
  */
638
638
  export async function planStreamed(id: string): Promise<StreamPlan> {
639
- const head = await readUpload(id, { end: HEADER_PROBE_BYTES });
639
+ const head = await stepReadUpload(id, { end: HEADER_PROBE_BYTES });
640
640
  const format = fatalOnUnsupported(() => parseWav(head.bytes, Number.POSITIVE_INFINITY));
641
641
  if (!Number.isFinite(format.dataEnd)) {
642
642
  return throwFatalStepError(
@@ -647,7 +647,7 @@ export async function planStreamed(id: string): Promise<StreamPlan> {
647
647
  );
648
648
  }
649
649
  const segments = fatalOnUnsupported(() => planSegments(format));
650
- await report(
650
+ await stepReport(
651
651
  `Planned ${formatDuration(segments.at(-1)?.endMs ?? 0)} of audio as ` +
652
652
  `${segments.length} ${plural(segments.length, "segment")} while it uploads.`,
653
653
  );
@@ -18,7 +18,7 @@
18
18
  * the same shape). It is called FROM steps, so it inherits their environment.
19
19
  */
20
20
 
21
- import { stepTranscribeSyncClassified } from "@alexkroman1/aai/step-errors";
21
+ import { stepTranscribeSyncOrFail } from "@alexkroman1/aai/step-errors";
22
22
 
23
23
  /**
24
24
  * Time one transcription, so the progress log carries LATENCY.
@@ -57,7 +57,7 @@ export function elapsed(ms: number): string {
57
57
  * concatenated straight into the request body rather than into an intermediate
58
58
  * buffer that doubles the segment's footprint.
59
59
  *
60
- * `stepTranscribeSyncClassified` — the SDK's own `stepTranscribeSync` plus
60
+ * `stepTranscribeSyncOrFail` — the SDK's own `stepTranscribeSync` plus
61
61
  * `throwStepError`, and nothing else — is the whole of what this adds to the SDK
62
62
  * call, and it is where the three-way call is made: a `FatalError` stops the DevKit retrying
63
63
  * something that will answer the same way, a bare `RetryableError` retries in ONE
@@ -75,6 +75,6 @@ export async function transcribeWav(
75
75
  filename: string,
76
76
  label: string,
77
77
  ): Promise<string> {
78
- const { text } = await stepTranscribeSyncClassified(bytes, { filename, label });
78
+ const { text } = await stepTranscribeSyncOrFail(bytes, { filename, label });
79
79
  return text;
80
80
  }
@@ -48,7 +48,7 @@
48
48
  * - **The audio is addressed by BYTE RANGE, never carried.** A workflow's input
49
49
  * is journaled and replayed on every resume, so the recording lives in the
50
50
  * app's own upload store and the run carries only its id; each step reads
51
- * exactly its own window with `readUpload`. Sixty steps therefore move the
51
+ * exactly its own window with `stepReadUpload`. Sixty steps therefore move the
52
52
  * recording once between them, not sixty times.
53
53
  * - **The fan-out is bounded by `mapConcurrent`, and the bound is not a detail.**
54
54
  * The DevKit correlates a journal entry to a step call by the ORDER the call
@@ -57,20 +57,20 @@
57
57
  * calls settle. Its module doc carries the argument; what matters here is that
58
58
  * there is no barrier, so a slow segment costs only itself.
59
59
  * - **The transcript STREAMS as it is produced.** Each segment is emitted the
60
- * moment it lands (`emit(TRANSCRIPT_STREAM, …)`), so the page renders the
60
+ * moment it lands (`stepEmit(TRANSCRIPT_STREAM, …)`), so the page renders the
61
61
  * answer growing rather than a status line and then everything at once. That is
62
62
  * the difference a fan-out can make to a reader and a run output cannot: an
63
63
  * `output` exists only when the last segment does.
64
64
  */
65
65
 
66
- import type { WorkflowCtx } from "@alexkroman1/aai";
66
+ import type { WorkflowContext } from "@alexkroman1/aai";
67
67
  import {
68
- emit,
69
68
  mapConcurrent,
70
- readUpload,
71
- report,
72
- requireCompleteUpload,
73
- uploadInfo,
69
+ stepEmit,
70
+ stepReadUpload,
71
+ stepReport,
72
+ stepRequireCompleteUpload,
73
+ stepUploadInfo,
74
74
  wavHeader,
75
75
  } from "@alexkroman1/aai/step";
76
76
  import { throwFatalStepError } from "@alexkroman1/aai/step-errors";
@@ -248,7 +248,7 @@ export type SegmentTranscript = {
248
248
  * The input is what `POST /workflows/runs` carries — see `agent.ts` for the
249
249
  * schema it is validated against before a run exists.
250
250
  */
251
- export async function transcribeFlow(input: { recording: string }, ctx: WorkflowCtx) {
251
+ export async function transcribeFlow(input: { recording: string }, ctx: WorkflowContext) {
252
252
  // Both at once: neither needs the other, and issued together they are one
253
253
  // round trip instead of two before any audio is read. The ORDER is still a
254
254
  // pure function of this line — the two calls go out synchronously, left to
@@ -313,13 +313,13 @@ export async function splitRecording(uploadId: string): Promise<{
313
313
  // half-arrived recording this planned a fan-out over the first half and the run
314
314
  // returned a transcript of it, reporting success. `stream.ts` is the flow for a
315
315
  // recording that is still landing; this one wants all of it.
316
- const stored = await requireCompleteUpload(uploadId);
317
- const head = await readUpload(uploadId, { end: HEADER_PROBE_BYTES });
316
+ const stored = await stepRequireCompleteUpload(uploadId);
317
+ const head = await stepReadUpload(uploadId, { end: HEADER_PROBE_BYTES });
318
318
  const format = fatalOnUnsupported(() => parseWav(head.bytes, stored.size));
319
319
  const segments = fatalOnUnsupported(() => planSegments(format));
320
320
  const durationMs = segments.at(-1)?.endMs ?? 0;
321
321
 
322
- await report(
322
+ await stepReport(
323
323
  `Split ${formatDuration(durationMs)} of audio into ${segments.length} ${plural(segments.length, "segment")}.`,
324
324
  );
325
325
  return { format, segments, durationMs };
@@ -345,12 +345,14 @@ export async function transcribeSegment(
345
345
  // calls together, so their lines interleave by completion — the page renders a
346
346
  // log, not a sequence, and `segment.index` is what puts the TRANSCRIPT back in
347
347
  // order.
348
- await report(`Transcribing ${formatDuration(segment.startMs)}–${formatDuration(segment.endMs)}.`);
348
+ await stepReport(
349
+ `Transcribing ${formatDuration(segment.startMs)}–${formatDuration(segment.endMs)}.`,
350
+ );
349
351
 
350
352
  // `[start, end)`, the same half-open pair `planSegments` produced — the store
351
353
  // owns the conversion to HTTP's inclusive range, so there is no `- 1` here to
352
354
  // get wrong.
353
- const audio = await readUpload(uploadId, { start: segment.start, end: segment.end });
355
+ const audio = await stepReadUpload(uploadId, { start: segment.start, end: segment.end });
354
356
 
355
357
  // The audio and nothing else. A `config` part carrying `language_code` used
356
358
  // to ride along, and it is gone with the picker that fed it: the model detects
@@ -401,15 +403,15 @@ export async function transcribeSegment(
401
403
  );
402
404
  // The LATENCY, which is what says whether the concurrency bound or the endpoint
403
405
  // is the thing limiting the run — see `timed`'s doc.
404
- await report(
406
+ await stepReport(
405
407
  `Transcribed ${formatDuration(segment.startMs)}–${formatDuration(segment.endMs)} in ${elapsed(ms)}.`,
406
408
  );
407
409
  // And the WORDS, into their own stream, which is what makes this run's answer
408
410
  // streamable rather than only its narration: the page stitches whatever has
409
411
  // arrived and renders the transcript growing, minutes before `output` exists.
410
- // Its own namespace because `report`'s stream carries sentences a page prints
411
- // verbatim — see `emit`'s doc.
412
- await emit(TRANSCRIPT_STREAM, {
412
+ // Its own namespace because `stepReport`'s stream carries sentences a page prints
413
+ // verbatim — see `stepEmit`'s doc.
414
+ await stepEmit(TRANSCRIPT_STREAM, {
413
415
  index: segment.index,
414
416
  startMs: segment.startMs,
415
417
  endMs: segment.endMs,
@@ -422,7 +424,7 @@ export async function transcribeSegment(
422
424
  * Stitch the segments into one transcript.
423
425
  *
424
426
  * A step rather than a pure call in the body, and the reason is the narration:
425
- * the body replays from the top on every resume, so a `report()` written there
427
+ * the body replays from the top on every resume, so a `stepReport()` written there
426
428
  * is re-emitted on each one. Journaling the finished transcript also means a
427
429
  * caller re-reading a completed run gets the same bytes rather than a value
428
430
  * recomputed from parts.
@@ -433,7 +435,7 @@ export async function mergeTranscript(
433
435
  parts: readonly SegmentTranscript[],
434
436
  startedAt: number,
435
437
  ): Promise<Transcript> {
436
- await report(`Stitching ${parts.length} ${plural(parts.length, "segment")} together.`);
438
+ await stepReport(`Stitching ${parts.length} ${plural(parts.length, "segment")} together.`);
437
439
 
438
440
  // `mapConcurrent` resolves in ITEM order however the calls settled, so this is
439
441
  // already ordered — sorted anyway, because the merge is where an ordering
@@ -443,7 +445,7 @@ export async function mergeTranscript(
443
445
 
444
446
  // The FILENAME, not the id: the page prints this, and `upl_9f3…` tells a
445
447
  // reader nothing about which recording they are looking at.
446
- const source = (await uploadInfo(uploadId)).name || uploadId;
448
+ const source = (await stepUploadInfo(uploadId)).name || uploadId;
447
449
  return {
448
450
  source,
449
451
  segments: parts.length,
@@ -457,19 +459,6 @@ export async function mergeTranscript(
457
459
  };
458
460
  }
459
461
 
460
- // Re-exported rather than re-declared: `stream.ts` and `batch.ts` already import
461
- // these from this module, and the split that let the PAGE stitch a partial
462
- // transcript should not ripple through every flow. `clock` and `countWords` used
463
- // to be in this list and are `formatDuration`/`countWords` on
464
- // `@alexkroman1/aai/utils` now — a run narrates itself and the page renders the
465
- // same run, so those two were a private copy of a formatter the SDK ships.
466
- export {
467
- stitchChunks,
468
- stitchTranscript,
469
- TRANSCRIPT_STREAM,
470
- type TranscriptChunk,
471
- } from "./stitch.ts";
472
-
473
462
  // ---- I/O helpers ------------------------------------------------------------
474
463
 
475
464
  /**