@alexkroman1/aai-cli 6.10.0 → 6.11.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 (29) hide show
  1. package/dist/scaffold/CLAUDE.md +58 -0
  2. package/dist/scaffold/package.json +3 -3
  3. package/dist/scaffold/server.mjs +12 -3
  4. package/dist/scaffold/vite.config.ts +1 -1
  5. package/dist/templates/call-audit/agent.test.ts +965 -0
  6. package/dist/templates/call-audit/agent.ts +158 -0
  7. package/dist/templates/call-audit/client.tsx +235 -0
  8. package/dist/templates/call-audit/workflows/audit.ts +305 -0
  9. package/dist/templates/call-audit/workflows/ingest.ts +259 -0
  10. package/dist/templates/call-audit/workflows/media.ts +647 -0
  11. package/dist/templates/call-audit/workflows/summarize.ts +206 -0
  12. package/dist/templates/call-audit/workflows/sync-api.ts +44 -0
  13. package/dist/templates/call-audit/workflows/temp-media.ts +138 -0
  14. package/dist/templates/recap-workflow/agent.test.ts +11 -3
  15. package/dist/templates/recap-workflow/workflows/recap.ts +19 -8
  16. package/dist/templates/spoken-summary/agent.test.ts +343 -0
  17. package/dist/templates/spoken-summary/agent.ts +142 -0
  18. package/dist/templates/spoken-summary/client.tsx +225 -0
  19. package/dist/templates/spoken-summary/workflows/summarize.ts +242 -0
  20. package/dist/templates/spoken-summary/workflows/transcribe.ts +145 -0
  21. package/dist/templates/transcription-workflow/agent.test.ts +241 -18
  22. package/dist/templates/transcription-workflow/agent.ts +20 -6
  23. package/dist/templates/transcription-workflow/workflows/batch.ts +75 -173
  24. package/dist/templates/transcription-workflow/workflows/normalize.ts +343 -0
  25. package/dist/templates/transcription-workflow/workflows/stream.ts +6 -4
  26. package/dist/templates/transcription-workflow/workflows/sync-api.ts +26 -94
  27. package/dist/templates/transcription-workflow/workflows/transcribe.ts +23 -14
  28. package/dist/templates/transcription-workflow/workflows/wav.ts +31 -0
  29. package/package.json +3 -3
@@ -151,6 +151,7 @@ import {
151
151
  transcribeSegment,
152
152
  } from "./transcribe.ts";
153
153
  import {
154
+ HEADER_PROBE_BYTES,
154
155
  offsetToMs,
155
156
  parseWav,
156
157
  planSegments,
@@ -176,9 +177,6 @@ const POLL_INTERVAL = "5s";
176
177
  */
177
178
  const MAX_IDLE_POLLS = 60;
178
179
 
179
- /** Bytes probed for the WAV header — the same window `splitRecording` uses. */
180
- const HEADER_PROBE_BYTES = 64 * 1024;
181
-
182
180
  /** What one poll of the upload found. */
183
181
  export type UploadProgressView = {
184
182
  /** Bytes stored so far. */
@@ -339,8 +337,12 @@ export async function planStreamed(id: string): Promise<StreamPlan> {
339
337
  * Not `plan.segments.length`: the plan came from the header's declared length, and a
340
338
  * recording that came up short has segments that start past the end of the file.
341
339
  * Counting those would leave the run waiting for audio nobody is going to send.
340
+ *
341
+ * Exported for its spec. It is the one piece of this flow's exit condition that is
342
+ * a pure function of journaled values, so it is the one a test can pin — and the
343
+ * failure it guards is a run that never ends rather than one that fails.
342
344
  */
343
- function expectedSegments(plan: StreamPlan, size: number): number {
345
+ export function expectedSegments(plan: StreamPlan, size: number): number {
344
346
  return plan.segments.filter((segment) => segment.start < size).length;
345
347
  }
346
348
 
@@ -1,35 +1,25 @@
1
1
  // Copyright 2026 the AAI authors. MIT license.
2
2
  /**
3
- * One request to AssemblyAI's synchronous transcription endpoint.
3
+ * What both SYNC flows share, now that the endpoint itself is the SDK's.
4
4
  *
5
- * Extracted when the second flow arrived, and the split is the one that was
6
- * already there: both flows send exactly the same request and differ only in
7
- * where the bytes came froma byte WINDOW of one stored recording
8
- * (`transcribe.ts`), or one PART of a group that is still being uploaded
9
- * (`stream.ts`). Everything that is a property of the endpoint rather than of the
10
- * caller lives here: the URL, the model header, the raw-key auth, the deadline,
11
- * the multipart shape, and the three-way failure classification.
5
+ * This module used to BE the endpoint: the URL, the model header, the raw-key auth,
6
+ * the deadline, the multipart shape and the three-way failure classification. All of
7
+ * that is `stepTranscribeSync` on `@alexkroman1/aai/utils`the same request, with
8
+ * `transcription-workflow`'s own hard-won details (the unprefixed key, the
9
+ * `X-AAI-Model` header, `stepFetch` rather than `fetch` so a fan-out's rate limit
10
+ * arrives as a status rather than as a stream reset) carried into it.
12
11
  *
13
- * No directive, which is what lets it live under `workflows/` beside the bodies:
14
- * the WDK builder scans this directory and transforms only what carries one
15
- * (`wav.ts` is the same shape). It is called FROM steps, so it inherits their
16
- * environment — `requireStepEnv` works here for the same reason it works there.
12
+ * What is left is what belongs to the CALLER rather than to the endpoint, and it is
13
+ * all measurement: both flows time each request, because per-part latency is the one
14
+ * number that says which bound is actually binding.
15
+ *
16
+ * No directive, which is what lets it live under `workflows/` beside the bodies: the
17
+ * WDK builder scans this directory and transforms only what carries one (`wav.ts` is
18
+ * the same shape). It is called FROM steps, so it inherits their environment.
17
19
  */
18
20
 
19
- import { throwFatalStepError, toStepError } from "@alexkroman1/aai/step-errors";
20
- import { multipartBody, requireStepEnv, stepFetch } from "@alexkroman1/aai/utils";
21
-
22
- /** The synchronous transcription endpoint. Global — it routes to the nearest region. */
23
- const SYNC_ENDPOINT = "https://sync.assemblyai.com/transcribe";
24
-
25
- /** Required on every sync request; the endpoint routes on it. */
26
- const SYNC_MODEL = "universal-3-5-pro";
27
-
28
- /** The key a step reads out of the agent env. Declared in `agent.ts`'s `requiredEnv`. */
29
- const API_KEY_ENV = "ASSEMBLYAI_API_KEY";
30
-
31
- /** The endpoint's own per-request deadline, plus room to upload. */
32
- const SYNC_TIMEOUT_MS = 60_000;
21
+ import { throwStepError } from "@alexkroman1/aai/step-errors";
22
+ import { stepTranscribeSync } from "@alexkroman1/aai/utils";
33
23
 
34
24
  /**
35
25
  * Time one transcription, so the progress log carries LATENCY.
@@ -65,6 +55,14 @@ export function elapsed(ms: number): string {
65
55
  * callers arrive at that differently: one re-attaches a header to a window it
66
56
  * read, the other is handed parts that already carry one.
67
57
  *
58
+ * `.catch(throwStepError)` is the whole of what this adds to the SDK call, and it is
59
+ * where the three-way call is made: a `FatalError` stops the DevKit retrying
60
+ * something that will answer the same way, a bare `RetryableError` retries in ONE
61
+ * SECOND (that class's own default), and a `RetryableError` carrying `retryAfter`
62
+ * waits exactly as long as the far side asked. The last matters here because a whole
63
+ * batch hits the rate limit together — a second later all of them ask again, where
64
+ * on the server's number they drain.
65
+ *
68
66
  * @param label - How this piece is named in a failure. The CALLER's vocabulary
69
67
  * (a segment's timestamp, a part's index), because it is what a reader of the
70
68
  * log has in front of them.
@@ -74,72 +72,6 @@ export async function transcribeWav(
74
72
  filename: string,
75
73
  label: string,
76
74
  ): Promise<string> {
77
- const apiKey = apiKeyOrFatal();
78
- const part = multipartBody({ name: "audio", filename, type: "audio/wav", bytes });
79
-
80
- // `stepFetch`, not `fetch`, and here it is load-bearing rather than tidy:
81
- // `fetch` speaks HTTP/2 wherever the far side offers it, which puts a whole
82
- // batch of segments on ONE connection — and a capacity limit then arrives as a
83
- // stream reset carrying no HTTP status for `toStepError` below to read. A
84
- // fan-out is exactly the shape that breaks on. `sdk/step-fetch.ts` holds the
85
- // measurements; a `StepTransportError` out of here is already retryable and
86
- // already names its cause.
87
- const response = await stepFetch(SYNC_ENDPOINT, {
88
- method: "POST",
89
- headers: {
90
- // The raw key — this endpoint takes it unprefixed, and a `Bearer ` in
91
- // front of it is a 401 that reads like a wrong key.
92
- Authorization: apiKey,
93
- "X-AAI-Model": SYNC_MODEL,
94
- ...part.headers,
95
- },
96
- body: part.body,
97
- // Nothing here has a deadline of its own, and a hung upload inside a step is
98
- // a run that never finishes rather than one that retries.
99
- signal: AbortSignal.timeout(SYNC_TIMEOUT_MS),
100
- });
101
- if (!response.ok) throw await syncFailure(response, label);
102
-
103
- const body = (await response.json()) as { text?: string };
104
- return (body.text ?? "").trim();
105
- }
106
-
107
- /**
108
- * The API key, or a terminal failure — three more attempts find the same gap.
109
- *
110
- * Exported because `batch.ts` calls the same provider on the same key and had
111
- * written this, and its own `API_KEY_ENV`, again. The key belongs to the
112
- * PROVIDER; only the endpoint and its failure shapes belong to this module.
113
- */
114
- export function apiKeyOrFatal(): string {
115
- try {
116
- return requireStepEnv(API_KEY_ENV);
117
- } catch (err: unknown) {
118
- // `throwFatalStepError` rather than `throw new FatalError(…)`: that class
119
- // takes only a message — no `cause` — so constructing one inside a `catch`
120
- // loses the original where the linter (rightly) expects it preserved. Here
121
- // the original is the ARGUMENT, and nothing is swallowed.
122
- return throwFatalStepError(err);
123
- }
124
- }
125
-
126
- /**
127
- * The sync endpoint's failure, with whatever it said about it.
128
- *
129
- * `toStepError` makes the three-way call: a `FatalError` stops the DevKit
130
- * retrying something that will answer the same way, a bare `RetryableError`
131
- * retries in ONE SECOND (that class's own default), and a `RetryableError`
132
- * carrying `retryAfter` waits exactly as long as the far side asked. The last
133
- * matters here because a whole batch hits the rate limit together — a second
134
- * later all of them ask again, where on the server's number they drain.
135
- */
136
- async function syncFailure(response: Response, label: string): Promise<Error> {
137
- // Two shapes, documented: `{ error_code, message }` for a request problem and
138
- // `{ detail }` for auth and rate limits.
139
- const body = (await response.json().catch(() => ({}))) as { message?: string; detail?: string };
140
- const detail = body.message ?? body.detail;
141
- return toStepError(
142
- response,
143
- `${label} failed: HTTP ${response.status}${detail ? ` — ${detail}` : ""}`,
144
- );
75
+ const { text } = await stepTranscribeSync(bytes, { filename, label }).catch(throwStepError);
76
+ return text;
145
77
  }
@@ -7,15 +7,21 @@
7
7
  * every directive body obeys — replayed from the top, so no live handles and no
8
8
  * undurable decisions; step arguments and return values are serialized, so pass
9
9
  * an id and not a payload — and both hold here unchanged. What this template
10
- * adds is the shape a real provider limit forces on a workflow, and it is three
10
+ * adds is the shape a real provider limit forces on a workflow, and it is four
11
11
  * steps in a straight line:
12
12
  *
13
13
  * ```text
14
+ * normalizeRecording one step → an upload id in a format that can be cut
14
15
  * splitRecording one step → the format + a byte range per segment
15
16
  * transcribeSegment N steps → one sync API request each, bounded
16
17
  * mergeTranscript one step → the stitched transcript
17
18
  * ```
18
19
  *
20
+ * The first is the newest and the one a reader is least likely to expect, since
21
+ * everything below it is arithmetic over a WAV and real recordings are not WAVs.
22
+ * `normalize.ts` is where ffmpeg enters, and its module doc carries why the
23
+ * conversion is file-to-file and why a temp file may not outlive its step.
24
+ *
19
25
  * ## Why the SYNC endpoint, and why that forces a fan-out
20
26
  *
21
27
  * AssemblyAI has two pre-recorded APIs. The BATCH one takes a job and a webhook
@@ -59,6 +65,7 @@
59
65
 
60
66
  import { throwFatalStepError } from "@alexkroman1/aai/step-errors";
61
67
  import { emit, mapConcurrent, readUpload, report, uploadInfo } from "@alexkroman1/aai/utils";
68
+ import { normalizeRecording } from "./normalize.ts";
62
69
  import {
63
70
  clock,
64
71
  countWords,
@@ -69,6 +76,7 @@ import {
69
76
  import { elapsed, timed, transcribeWav } from "./sync-api.ts";
70
77
  import {
71
78
  bytesPerSecond,
79
+ HEADER_PROBE_BYTES,
72
80
  parseWav,
73
81
  planSegments,
74
82
  SEGMENT_OVERLAP_SECONDS,
@@ -186,15 +194,6 @@ export function segmentConcurrency(format: WavFormat): number {
186
194
  return Math.max(1, Math.min(MAX_SEGMENT_CONCURRENCY, Math.floor(BYTES_IN_FLIGHT / perSegment)));
187
195
  }
188
196
 
189
- /**
190
- * Bytes probed for the WAV header.
191
- *
192
- * The canonical header is 44 bytes; a recorder that writes a `LIST` or `bext`
193
- * chunk in front of the samples pushes the `data` chunk further out, and 64 KB
194
- * covers every such file anyone has produced by accident.
195
- */
196
- const HEADER_PROBE_BYTES = 64 * 1024;
197
-
198
197
  /**
199
198
  * What a finished run reports, whichever flow produced it.
200
199
  *
@@ -234,7 +233,16 @@ export async function transcribeFlow(input: { recording: string }) {
234
233
  // round trip instead of two before any audio is read. The ORDER is still a
235
234
  // pure function of this line — the two calls go out synchronously, left to
236
235
  // right — which is what a replay reproduces.
237
- const [startedAt, plan] = await Promise.all([startClock(), splitRecording(input.recording)]);
236
+ //
237
+ // The clock starts before the conversion rather than after it, because a
238
+ // reader comparing the three flows over one file is comparing what the desk
239
+ // COST them, and re-encoding an m4a is part of that.
240
+ const [startedAt, ready] = await Promise.all([startClock(), normalizeRecording(input.recording)]);
241
+
242
+ // `ready.recording` from here on, not `input.recording`: a converted file is a
243
+ // DIFFERENT upload, and cutting the original by offsets planned against the
244
+ // converted one is a fan-out of garbage that still reports success.
245
+ const plan = await splitRecording(ready.recording);
238
246
 
239
247
  // One step per segment, bounded, in an order a replay reproduces exactly.
240
248
  // A failed segment fails the RUN, deliberately: every sibling that finished is
@@ -242,11 +250,12 @@ export async function transcribeFlow(input: { recording: string }) {
242
250
  // what is missing, where catching here to salvage a partial transcript would
243
251
  // return a recording with a silent hole in it and report success.
244
252
  const parts = await mapConcurrent(plan.segments, segmentConcurrency(plan.format), (segment) =>
245
- transcribeSegment(input.recording, plan.format, segment),
253
+ transcribeSegment(ready.recording, plan.format, segment),
246
254
  );
247
255
 
248
- // Whatever this returns is what a caller reads as `output` on a completed run
249
- // so it is what the page renders, typed through `WorkflowOutputOf`.
256
+ // The ORIGINAL id, and only here: `mergeTranscript` uses it for the filename a
257
+ // reader sees, and `standup.m4a` is the recording they uploaded where the
258
+ // converted copy is an artifact of how the desk works.
250
259
  return await mergeTranscript(input.recording, plan.durationMs, parts, startedAt);
251
260
  }
252
261
 
@@ -26,6 +26,21 @@
26
26
  * anything else by name rather than trying to be clever — an MP3 or an M4A frame
27
27
  * boundary cannot be found by arithmetic, and finding it means shipping a
28
28
  * decoder into a step.
29
+ *
30
+ * ## Who READS the refusals below
31
+ *
32
+ * {@link UnsupportedRecordingError} is raised from here and handled in two
33
+ * completely different ways, which is worth knowing before editing a message:
34
+ *
35
+ * - The plain `transcribe` flow CONVERTS rather than refusing. `normalize.ts`
36
+ * calls {@link parseWav} as a QUESTION — a throw is its signal to hand the file
37
+ * to ffmpeg — so on that path no message here reaches a person, and a file this
38
+ * module rejects for a huge {@link MAX_BYTES_PER_SECOND} is one that
39
+ * normalization fixes by downsampling.
40
+ * - `transcribeStream` still refuses, and has to: it cuts a recording while the
41
+ * bytes are still arriving, and a partial file is not something ffmpeg can
42
+ * transcode. There the sentences below are the whole of what a person is told,
43
+ * so they keep naming the `ffmpeg` line that fixes the file.
29
44
  */
30
45
 
31
46
  /** What the sync endpoint will accept in one request. */
@@ -57,6 +72,22 @@ export const MAX_SEGMENT_BYTES = 40 * 1024 * 1024;
57
72
  /** The sync endpoint refuses audio shorter than this. */
58
73
  export const MIN_SEGMENT_MS = 80;
59
74
 
75
+ /**
76
+ * Bytes probed for the WAV header.
77
+ *
78
+ * The canonical header is 44 bytes; a recorder that writes a `LIST` or `bext`
79
+ * chunk in front of the samples pushes the `data` chunk further out, and 64 KB
80
+ * covers every such file anyone has produced by accident.
81
+ *
82
+ * Declared HERE because three callers need the same window, and the third one is
83
+ * what made a shared constant the rule rather than a preference: `splitRecording`
84
+ * cuts on it, `planFromHead` in `stream.ts` cuts on it, and `normalize.ts` decides
85
+ * whether to CONVERT on it. Two of those probing a different amount than the third
86
+ * is a file the desk converts and then cannot cut, or refuses and then converts —
87
+ * both silent, and neither reproducible from any one module.
88
+ */
89
+ export const HEADER_PROBE_BYTES = 64 * 1024;
90
+
60
91
  /**
61
92
  * The largest `bytesPerSecond` this desk will cut, and the reason it is checked
62
93
  * at PARSE time rather than at cut time.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alexkroman1/aai-cli",
3
- "version": "6.10.0",
3
+ "version": "6.11.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "aai": "bin.mjs"
@@ -44,8 +44,8 @@
44
44
  "p-timeout": "^7.0.1",
45
45
  "vite": "^8.2.1",
46
46
  "zod": "^4.4.3",
47
- "@alexkroman1/aai": "6.10.0",
48
- "@alexkroman1/aai-ui": "6.10.0"
47
+ "@alexkroman1/aai": "6.11.0",
48
+ "@alexkroman1/aai-ui": "6.11.0"
49
49
  },
50
50
  "devDependencies": {
51
51
  "playwright": "^1.62.1",