@alexkroman1/aai-cli 6.10.1 → 7.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.
- package/dist/{_agent-DpH2pBJd.mjs → _agent-BTwZJu4P.mjs} +2 -2
- package/dist/{_bundler-C2BDwU7r.mjs → _bundler-B4RqNF5Z.mjs} +2 -2
- package/dist/_dev-env.d.ts +22 -2
- package/dist/{_dev-server-LLLWnFBs.mjs → _dev-server-CiKFH9qw.mjs} +38 -47
- package/dist/_dev-server.d.ts +0 -17
- package/dist/{_init-C57bWAQ8.mjs → _init-CE4HKdgV.mjs} +3 -2
- package/dist/{_preflight-BtfaYtbE.mjs → _preflight-Co1G9Jww.mjs} +1 -1
- package/dist/_preflight.d.ts +1 -1
- package/dist/{_slug-api-BxrsJXVI.mjs → _slug-api-nRFaBEPJ.mjs} +2 -2
- package/dist/{_studio-U3jhKlA3.mjs → _studio-DR5yzZ1T.mjs} +2 -1
- package/dist/{_templates-Dxb_P2Wz.mjs → _templates-FI3xPEMj.mjs} +1 -1
- package/dist/_workflow-scan.d.ts +108 -0
- package/dist/{build-Dfza2pRI.mjs → build-6jfaakgc.mjs} +5 -2
- package/dist/cli.mjs +30 -25
- package/dist/{client-bundler-BEIqgOtd.mjs → client-bundler-CWnG42cU.mjs} +2 -1
- package/dist/client-bundler.mjs +1 -1
- package/dist/{delete-vbpjGzqs.mjs → delete-Dl_LSfvI.mjs} +3 -3
- package/dist/{deploy-B8lubiRT.mjs → deploy-DvYeD8ia.mjs} +5 -5
- package/dist/{dev-Uq5ujP8a.mjs → dev-B59JIfWO.mjs} +1 -1
- package/dist/{eject-31gjtaHF.mjs → eject-C9WJyyr2.mjs} +1 -1
- package/dist/{init-l6xfU4xX.mjs → init-CpJVjEg0.mjs} +3 -3
- package/dist/{login-C71-qz8F.mjs → login-BBuM1sxH.mjs} +3 -4
- package/dist/{logs-BWVGSjLR.mjs → logs-9q-psa4q.mjs} +2 -2
- package/dist/project-config.mjs +1 -1
- package/dist/scaffold/CLAUDE.md +93 -14
- package/dist/scaffold/package.json +5 -3
- package/dist/scaffold/server.mjs +13 -4
- package/dist/scaffold/vite.config.ts +1 -1
- package/dist/{secret-BuMuFR4B.mjs → secret-CVvSLIDV.mjs} +2 -2
- package/dist/{storage-DzRZ-eCw.mjs → storage-BvUrnvM3.mjs} +2 -2
- package/dist/{studio-BCNUpDgP.mjs → studio--MUV0cid.mjs} +4 -4
- package/dist/templates/call-audit/agent.test.ts +818 -0
- package/dist/templates/call-audit/agent.ts +171 -0
- package/dist/templates/call-audit/client.tsx +198 -0
- package/dist/templates/call-audit/workflows/audit.ts +298 -0
- package/dist/templates/call-audit/workflows/ingest.ts +251 -0
- package/dist/templates/call-audit/workflows/media.ts +637 -0
- package/dist/templates/call-audit/workflows/summarize.ts +201 -0
- package/dist/templates/call-audit/workflows/sync-api.ts +44 -0
- package/dist/templates/dispatch-center/agent.test.ts +149 -25
- package/dist/templates/dispatch-center/client.tsx +239 -129
- package/dist/templates/dispatch-center/shared.ts +99 -1
- package/dist/templates/dispatch-center/system-prompt.md +3 -1
- package/dist/templates/dispatch-center/tools/incident_add_note.ts +16 -13
- package/dist/templates/dispatch-center/tools/incident_create.ts +19 -1
- package/dist/templates/dispatch-center/tools/incident_escalate.ts +68 -59
- package/dist/templates/dispatch-center/tools/incident_triage.ts +57 -43
- package/dist/templates/dispatch-center/tools/incident_update_status.ts +49 -40
- package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +8 -2
- package/dist/templates/dispatch-center/tools/resources_dispatch.ts +69 -52
- package/dist/templates/dispatch-center/tools/resources_update_status.ts +45 -41
- package/dist/templates/embedded-assets/agent.test.ts +17 -14
- package/dist/templates/health-assistant/agent.test.ts +22 -4
- package/dist/templates/infocom-adventure/agent.test.ts +4 -6
- package/dist/templates/infocom-adventure/client.tsx +246 -164
- package/dist/templates/link-digest/agent.test.ts +24 -19
- package/dist/templates/link-digest/client.tsx +47 -61
- package/dist/templates/link-digest/workflows/digest.ts +19 -31
- package/dist/templates/night-owl/agent.test.ts +70 -19
- package/dist/templates/night-owl/agent.ts +5 -0
- package/dist/templates/night-owl/client.tsx +56 -56
- package/dist/templates/night-owl/shared.ts +24 -0
- package/dist/templates/night-owl/tools/recommend.ts +18 -6
- package/dist/templates/pizza-ordering/agent.test.ts +27 -27
- package/dist/templates/pizza-ordering/client.tsx +9 -26
- package/dist/templates/plan-and-execute/agent.test.ts +99 -58
- package/dist/templates/plan-and-execute/agent.ts +1 -1
- package/dist/templates/plan-and-execute/client.tsx +12 -15
- package/dist/templates/plan-and-execute/shared.ts +71 -2
- package/dist/templates/plan-and-execute/tools/plan_status.ts +18 -6
- package/dist/templates/plan-and-execute/tools/revise_plan.ts +18 -10
- package/dist/templates/plan-and-execute/tools/start_plan.ts +13 -2
- package/dist/templates/plan-and-execute/tools/work_next_step.ts +70 -22
- package/dist/templates/podcast-digest/agent.test.ts +746 -0
- package/dist/templates/podcast-digest/agent.ts +139 -0
- package/dist/templates/podcast-digest/client.tsx +154 -0
- package/dist/templates/podcast-digest/workflows/digest.ts +411 -0
- package/dist/templates/podcast-digest/workflows/feeds.ts +507 -0
- package/dist/templates/podcast-digest/workflows/slack.ts +209 -0
- package/dist/templates/recap-workflow/agent.test.ts +107 -96
- package/dist/templates/recap-workflow/tools/recap_progress.ts +9 -12
- package/dist/templates/recap-workflow/tools/recap_status.ts +8 -9
- package/dist/templates/recap-workflow/tools/request_recap.ts +2 -1
- package/dist/templates/recap-workflow/workflows/recap.ts +48 -41
- package/dist/templates/redline/agent.test.ts +17 -15
- package/dist/templates/redline/client.tsx +12 -12
- package/dist/templates/redline/workflows/redline.ts +19 -31
- package/dist/templates/research-workflow/agent.test.ts +60 -59
- package/dist/templates/research-workflow/tools/research_progress.ts +9 -12
- package/dist/templates/research-workflow/tools/research_status.ts +9 -11
- package/dist/templates/research-workflow/workflows/research.ts +44 -61
- package/dist/templates/retail/agent.test.ts +26 -23
- package/dist/templates/retail/client.tsx +226 -117
- package/dist/templates/retail/registry.test.ts +38 -6
- package/dist/templates/retail/store.test.ts +82 -15
- package/dist/templates/retail/store.ts +174 -47
- package/dist/templates/retail/system-prompt.md +11 -2
- package/dist/templates/retail/tools/cancel_pending_order.ts +2 -2
- package/dist/templates/retail/tools/exchange_delivered_order_items.ts +2 -2
- package/dist/templates/retail/tools/find_user_id_by_email.ts +8 -5
- package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +4 -5
- package/dist/templates/retail/tools/get_item_details.ts +3 -4
- package/dist/templates/retail/tools/get_order_details.ts +3 -4
- package/dist/templates/retail/tools/get_product_details.ts +3 -4
- package/dist/templates/retail/tools/get_user_details.ts +2 -2
- package/dist/templates/retail/tools/list_all_product_types.ts +5 -5
- package/dist/templates/retail/tools/modify_pending_order_address.ts +2 -2
- package/dist/templates/retail/tools/modify_pending_order_items.ts +2 -3
- package/dist/templates/retail/tools/modify_pending_order_payment.ts +2 -2
- package/dist/templates/retail/tools/modify_user_address.ts +2 -2
- package/dist/templates/retail/tools/return_delivered_order_items.ts +2 -2
- package/dist/templates/retail/tools/transfer_to_human_agents.ts +8 -4
- package/dist/templates/solo-rpg/agent.test.ts +227 -48
- package/dist/templates/solo-rpg/client.tsx +38 -37
- package/dist/templates/solo-rpg/shared.ts +145 -19
- package/dist/templates/solo-rpg/system-prompt.md +3 -2
- package/dist/templates/solo-rpg/tools/action_roll.ts +82 -63
- package/dist/templates/solo-rpg/tools/burn_momentum.ts +85 -50
- package/dist/templates/solo-rpg/tools/check_state.ts +24 -7
- package/dist/templates/solo-rpg/tools/load_game.ts +13 -1
- package/dist/templates/solo-rpg/tools/save_game.ts +16 -7
- package/dist/templates/solo-rpg/tools/setup_character.ts +22 -2
- package/dist/templates/solo-rpg/tools/update_state.ts +113 -100
- package/dist/templates/spoken-summary/agent.test.ts +314 -0
- package/dist/templates/spoken-summary/agent.ts +155 -0
- package/dist/templates/spoken-summary/client.tsx +185 -0
- package/dist/templates/spoken-summary/workflows/summarize.ts +237 -0
- package/dist/templates/spoken-summary/workflows/transcribe.ts +138 -0
- package/dist/templates/support-line/agent.test.ts +11 -16
- package/dist/templates/support-line/agent.ts +1 -1
- package/dist/templates/support-line/client.tsx +9 -9
- package/dist/templates/support-line/nodes.ts +100 -0
- package/dist/templates/support-line/procedure.ts +407 -0
- package/dist/templates/support-line/tools/answer_question.ts +17 -4
- package/dist/templates/transcription-workflow/agent.test.ts +246 -123
- package/dist/templates/transcription-workflow/agent.ts +21 -7
- package/dist/templates/transcription-workflow/client.tsx +17 -42
- package/dist/templates/transcription-workflow/workflows/batch.ts +79 -180
- package/dist/templates/transcription-workflow/workflows/normalize.ts +247 -0
- package/dist/templates/transcription-workflow/workflows/stitch.ts +0 -11
- package/dist/templates/transcription-workflow/workflows/stream.ts +10 -9
- package/dist/templates/transcription-workflow/workflows/sync-api.ts +26 -94
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +55 -40
- package/dist/templates/transcription-workflow/workflows/wav.ts +31 -31
- package/dist/templates/travel-concierge/agent.test.ts +64 -33
- package/dist/templates/travel-concierge/client.tsx +11 -23
- package/dist/templates/travel-concierge/routing.ts +34 -15
- package/dist/templates/travel-concierge/shared.ts +70 -3
- package/dist/templates/travel-concierge/tools/book_car_rental.ts +2 -2
- package/dist/templates/travel-concierge/tools/book_excursion.ts +2 -2
- package/dist/templates/travel-concierge/tools/book_hotel.ts +2 -2
- package/dist/templates/travel-concierge/tools/cancel_ticket.ts +2 -2
- package/dist/templates/travel-concierge/tools/update_ticket.ts +2 -2
- package/dist/{worker-bundler-CGD4r8Kc.mjs → worker-bundler-COxnqstQ.mjs} +221 -3
- package/dist/worker-bundler.mjs +1 -1
- package/dist/{workflow-CFpxOFfQ.mjs → workflow-D2AQf2Pl.mjs} +28 -1
- package/dist/workflow-bundler.d.ts +6 -1
- package/dist/workflow.d.ts +1 -1
- package/package.json +5 -4
- package/dist/templates/support-line/graph.ts +0 -224
- package/dist/{_config-D_s09e7g.mjs → _config-CmJOFsAP.mjs} +1 -1
- /package/dist/templates/plan-and-execute/{graph.ts → procedure.ts} +0 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// Copyright 2026 the AAI authors. MIT license.
|
|
2
|
+
/**
|
|
3
|
+
* The step that makes the rest of the desk possible on a real file: whatever was
|
|
4
|
+
* uploaded, converted to the one format the arithmetic works on.
|
|
5
|
+
*
|
|
6
|
+
* `wav.ts` explains why this desk cuts linear-PCM WAV and nothing else — a byte
|
|
7
|
+
* offset is only a timestamp when every sample is the same size — and for a long
|
|
8
|
+
* time the remedy for anything else was a SENTENCE telling the caller to run
|
|
9
|
+
* `ffmpeg -i in.m4a -c:a pcm_s16le out.wav` on their own machine first. Every
|
|
10
|
+
* recording anyone actually has is an `.m4a` off a phone or an `.mp3` out of a
|
|
11
|
+
* conferencing tool, so that sentence was the desk's real front door, and it
|
|
12
|
+
* opened onto the user's shell. The platform installs ffmpeg in every guest
|
|
13
|
+
* image; this file is the desk using it.
|
|
14
|
+
*
|
|
15
|
+
* ```text
|
|
16
|
+
* normalizeRecording one step → an upload id the rest of the flow can cut
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* ## `parseWav` is asked as a QUESTION
|
|
20
|
+
*
|
|
21
|
+
* The obvious implementation probes the file with `ffprobe` and passes it
|
|
22
|
+
* through when the codec looks like PCM. It is wrong in a way that only shows up
|
|
23
|
+
* on a Windows recorder's output: a `WAVE_FORMAT_EXTENSIBLE` file reports
|
|
24
|
+
* `pcm_s16le` to ffprobe and is refused by {@link parseWav}, whose encoding
|
|
25
|
+
* check reads the format tag ffprobe does not surface. The desk would convert
|
|
26
|
+
* nothing and then fail to cut it.
|
|
27
|
+
*
|
|
28
|
+
* So the test is {@link parseWav} ITSELF, run against the same
|
|
29
|
+
* {@link HEADER_PROBE_BYTES} window `splitRecording` will use. A throw is the
|
|
30
|
+
* signal to convert. That makes the pass-through decision and the cut decision
|
|
31
|
+
* the same decision by construction — there is no second opinion to disagree —
|
|
32
|
+
* and it means the desk fixes anything the parser rejects for any reason,
|
|
33
|
+
* including a 192 kHz 32-bit stereo WAV that trips
|
|
34
|
+
* {@link MAX_BYTES_PER_SECOND}, which downsampling genuinely repairs.
|
|
35
|
+
*
|
|
36
|
+
* The fast path costs one 64 KB read and no subprocess at all: a WAV that was
|
|
37
|
+
* already cuttable is returned by the id it came in under, so nothing is copied
|
|
38
|
+
* and nothing is re-encoded.
|
|
39
|
+
*
|
|
40
|
+
* ## File → file, not bytes → bytes
|
|
41
|
+
*
|
|
42
|
+
* `transcodeToWav(bytes)` is one line and is the wrong call here, and
|
|
43
|
+
* `@alexkroman1/aai/step-files` is the three functions that replace it —
|
|
44
|
+
* `readUploadToFile`, `withTempDir` and `writeUploadFromFile`. Its module doc
|
|
45
|
+
* carries the whole argument (a pipe cannot seek, so an `.m4a` with a trailing
|
|
46
|
+
* `moov` index fails; piped output is capped at 64 MiB, half an hour of this
|
|
47
|
+
* desk's audio) plus the rule that a temp file may not outlive its step. This
|
|
48
|
+
* step is the case those were written for.
|
|
49
|
+
*
|
|
50
|
+
* ## Everything node-shaped is named from inside the step BODY
|
|
51
|
+
*
|
|
52
|
+
* `@alexkroman1/aai/ffmpeg` and `@alexkroman1/aai/step-files` both reach a
|
|
53
|
+
* `node:` builtin, and a name this module holds at MODULE scope keeps its import
|
|
54
|
+
* in the workflow bundle — which is compiled as a `node:vm` Script with no
|
|
55
|
+
* `require`. Every name they bind is referenced only inside
|
|
56
|
+
* {@link normalizeRecording}'s body, which the workflow transform removes along
|
|
57
|
+
* with the imports it is the only user of. A module-scope FUNCTION naming one is
|
|
58
|
+
* what breaks a run at replay; this template used to carry a whole
|
|
59
|
+
* `ffmpeg-verdict.ts` because of it, and `throwFfmpegStepError` — which reaches
|
|
60
|
+
* no `node:` builtin at all — is what dissolved the boundary.
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
import { basename, extname, join } from "node:path";
|
|
64
|
+
import { probeMedia, runFfmpeg, wavEncodeArgs } from "@alexkroman1/aai/ffmpeg";
|
|
65
|
+
import { readUpload, report, uploadInfo } from "@alexkroman1/aai/step";
|
|
66
|
+
import { throwFfmpegStepError } from "@alexkroman1/aai/step-errors";
|
|
67
|
+
import { readUploadToFile, withTempDir, writeUploadFromFile } from "@alexkroman1/aai/step-files";
|
|
68
|
+
import { formatBytes, formatDuration } from "@alexkroman1/aai/utils";
|
|
69
|
+
import { HEADER_PROBE_BYTES, parseWav, UnsupportedRecordingError } from "./wav.ts";
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The rate everything is converted TO.
|
|
73
|
+
*
|
|
74
|
+
* 16 kHz because that is what speech models are trained at — a higher rate
|
|
75
|
+
* carries no information the decoder uses and costs proportional bytes in a
|
|
76
|
+
* fan-out whose width is bounded by bytes in flight (`BYTES_IN_FLIGHT` in
|
|
77
|
+
* `transcribe.ts`). A converted two-hour recording is 230 MB of 16 kHz mono
|
|
78
|
+
* against 1.4 GB of 48 kHz stereo, which is the difference between a fan-out
|
|
79
|
+
* that saturates on width and one that saturates on the queue.
|
|
80
|
+
*/
|
|
81
|
+
export const NORMALIZED_SAMPLE_RATE = 16_000;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Channels everything is converted TO.
|
|
85
|
+
*
|
|
86
|
+
* Mono, and it is a real loss rather than a free win: a stereo call recording
|
|
87
|
+
* with one party per channel is exactly the file where the channels are the most
|
|
88
|
+
* interesting thing about it, and downmixing throws that away. This desk
|
|
89
|
+
* transcribes rather than diarizes, so it takes the 2x saving; a desk that wants
|
|
90
|
+
* the speakers apart splits the channels first and transcribes each one.
|
|
91
|
+
*/
|
|
92
|
+
export const NORMALIZED_CHANNELS = 1;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* How long a conversion may run before it is killed.
|
|
96
|
+
*
|
|
97
|
+
* Well past what the work takes — ffmpeg decodes and resamples faster than
|
|
98
|
+
* realtime by two orders of magnitude, so a two-hour recording is under a
|
|
99
|
+
* minute — and the reason for a bound at all is a file that makes a decoder
|
|
100
|
+
* pathological rather than one that is merely long. A `timeout` is retryable
|
|
101
|
+
* and an `exit` is not; `throwFfmpegStepError` decides.
|
|
102
|
+
*/
|
|
103
|
+
const CONVERT_TIMEOUT_MS = 15 * 60_000;
|
|
104
|
+
|
|
105
|
+
/** What the flow is handed: the id to cut, and whether it had to be made. */
|
|
106
|
+
export type NormalizedRecording = {
|
|
107
|
+
/**
|
|
108
|
+
* The upload id every later step reads.
|
|
109
|
+
*
|
|
110
|
+
* The SAME id that came in when the file was already cuttable, and a new one
|
|
111
|
+
* when it was converted — which is why the flow threads this rather than its
|
|
112
|
+
* own input from here on.
|
|
113
|
+
*/
|
|
114
|
+
recording: string;
|
|
115
|
+
/** Whether ffmpeg ran. Reported, so a reader can tell a fast path from a slow one. */
|
|
116
|
+
converted: boolean;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Make sure the recording is something the desk can cut, converting if not.
|
|
121
|
+
*
|
|
122
|
+
* A step, for the ordinary two reasons — it does I/O, and its RESULT is what
|
|
123
|
+
* every later step addresses — plus one specific to what it produces: the
|
|
124
|
+
* conversion writes a file, and journaling the id means a resumed run reads the
|
|
125
|
+
* file that already exists instead of paying for a second one.
|
|
126
|
+
*/
|
|
127
|
+
export async function normalizeRecording(uploadId: string): Promise<NormalizedRecording> {
|
|
128
|
+
"use step";
|
|
129
|
+
|
|
130
|
+
const stored = await uploadInfo(uploadId);
|
|
131
|
+
const head = await readUpload(uploadId, { end: HEADER_PROBE_BYTES });
|
|
132
|
+
|
|
133
|
+
if (cuttable(head.bytes, stored.size)) {
|
|
134
|
+
// No subprocess, no copy, no second upload. The overwhelmingly common case
|
|
135
|
+
// for a desk whose form says WAV, and the reason the check is a 64 KB read.
|
|
136
|
+
await report(`${stored.name || uploadId} is already linear-PCM WAV — cutting it as it is.`);
|
|
137
|
+
return { recording: uploadId, converted: false };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Named before any work starts, because everything below is minutes of it on a
|
|
141
|
+
// long recording and a run that says nothing until the conversion finishes looks
|
|
142
|
+
// stuck. It is also the line that distinguishes "this file needs converting" from
|
|
143
|
+
// the fast path above.
|
|
144
|
+
await report(
|
|
145
|
+
`Converting ${stored.name || uploadId} (${formatBytes(stored.size)}) — not a WAV we can cut.`,
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
// The temp directory's lifetime is this lexical scope, and the `finally` inside
|
|
149
|
+
// `withTempDir` is what makes that true on the failure paths too: a guest's disk
|
|
150
|
+
// is small and a step that leaves a copy of every recording it touched fills it.
|
|
151
|
+
return await withTempDir(
|
|
152
|
+
async (dir) => {
|
|
153
|
+
const source = join(dir, "source");
|
|
154
|
+
const converted = join(dir, "converted.wav");
|
|
155
|
+
|
|
156
|
+
await readUploadToFile(uploadId, source, { size: stored.size });
|
|
157
|
+
|
|
158
|
+
// What it WAS, for the progress line. Worth one ffprobe: "converted 41
|
|
159
|
+
// minutes of aac" is a line that explains the run's shape, where
|
|
160
|
+
// "converted the recording" leaves a reader wondering what the desk decided.
|
|
161
|
+
// On a temp file rather than a pipe, so a trailing index is readable.
|
|
162
|
+
const info = await probeMedia(source, { timeoutMs: CONVERT_TIMEOUT_MS }).catch(
|
|
163
|
+
throwFfmpegStepError,
|
|
164
|
+
);
|
|
165
|
+
await report(
|
|
166
|
+
`It is ${describeSource(info.audio?.codec, info.durationSec)} — re-encoding to ` +
|
|
167
|
+
`${NORMALIZED_SAMPLE_RATE / 1000} kHz mono WAV.`,
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
await runFfmpeg(
|
|
171
|
+
[
|
|
172
|
+
// The argv is the caller's, verbatim — `runFfmpeg` adds nothing. So the
|
|
173
|
+
// standing flags are here: quiet, non-interactive, overwrite. `-nostdin`
|
|
174
|
+
// matters most in a guest, where there is no terminal and an ffmpeg that
|
|
175
|
+
// decides to read stdin is a process that never exits.
|
|
176
|
+
"-hide_banner",
|
|
177
|
+
"-loglevel",
|
|
178
|
+
"error",
|
|
179
|
+
"-nostdin",
|
|
180
|
+
"-y",
|
|
181
|
+
"-i",
|
|
182
|
+
source,
|
|
183
|
+
...wavEncodeArgs({
|
|
184
|
+
sampleRate: NORMALIZED_SAMPLE_RATE,
|
|
185
|
+
channels: NORMALIZED_CHANNELS,
|
|
186
|
+
}),
|
|
187
|
+
converted,
|
|
188
|
+
],
|
|
189
|
+
{ timeoutMs: CONVERT_TIMEOUT_MS },
|
|
190
|
+
).catch(throwFfmpegStepError);
|
|
191
|
+
|
|
192
|
+
const written = await writeUploadFromFile(converted, {
|
|
193
|
+
// Named after the ORIGINAL, so a download reads as the recording it came
|
|
194
|
+
// from. The extension has to change with the bytes: a file served as
|
|
195
|
+
// `audio/wav` under a `.m4a` name is one no player will open.
|
|
196
|
+
name: `${basename(stored.name || uploadId, extname(stored.name || uploadId))}.wav`,
|
|
197
|
+
type: "audio/wav",
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
await report(
|
|
201
|
+
`Converted to ${formatBytes(written.size)} of WAV (from ${formatBytes(stored.size)}).`,
|
|
202
|
+
);
|
|
203
|
+
return { recording: written.id, converted: true };
|
|
204
|
+
},
|
|
205
|
+
// Named after the pipeline: the directory is gone by the time anyone looks,
|
|
206
|
+
// so the prefix's real audience is a person reading `ls /tmp` during a run
|
|
207
|
+
// that hung, and the spec that asserts nothing was left behind.
|
|
208
|
+
{ prefix: "aai-normalize-" },
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Retries beyond the default 3.
|
|
214
|
+
*
|
|
215
|
+
* Not because a conversion is flaky — a corrupt file fails identically forever,
|
|
216
|
+
* and `throwFfmpegStepError` is what stops the DevKit retrying that. It is the
|
|
217
|
+
* two I/O halves that are worth another attempt: this step reads a whole
|
|
218
|
+
* recording out of the store and writes a whole one back, and either can lose a
|
|
219
|
+
* connection on a file this size.
|
|
220
|
+
*/
|
|
221
|
+
normalizeRecording.maxRetries = 5;
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Whether `splitRecording` will be able to read this header.
|
|
225
|
+
*
|
|
226
|
+
* The question, not a guess at it — see the module doc. Only
|
|
227
|
+
* {@link UnsupportedRecordingError} is answered `false`: anything else thrown by
|
|
228
|
+
* the parser is a bug in the parser, and swallowing it here would turn that into
|
|
229
|
+
* a mysterious re-encode of a file that was fine.
|
|
230
|
+
*/
|
|
231
|
+
export function cuttable(head: Uint8Array, totalBytes: number): boolean {
|
|
232
|
+
try {
|
|
233
|
+
parseWav(head, totalBytes);
|
|
234
|
+
return true;
|
|
235
|
+
} catch (err: unknown) {
|
|
236
|
+
if (err instanceof UnsupportedRecordingError) return false;
|
|
237
|
+
throw err;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** `41:20 of aac`, or as much of that as ffprobe would say. */
|
|
242
|
+
function describeSource(codec: string | undefined, durationSec: number | undefined): string {
|
|
243
|
+
const length =
|
|
244
|
+
durationSec === undefined ? undefined : formatDuration(Math.round(durationSec * 1000));
|
|
245
|
+
if (length !== undefined && codec !== undefined) return `${length} of ${codec}`;
|
|
246
|
+
return length ?? codec ?? "the recording";
|
|
247
|
+
}
|
|
@@ -120,14 +120,3 @@ function seamLength(merged: readonly string[], next: readonly string[]): number
|
|
|
120
120
|
export function stitchChunks(chunks: readonly TranscriptChunk[]): string {
|
|
121
121
|
return stitchTranscript([...chunks].sort((a, b) => a.index - b.index).map((chunk) => chunk.text));
|
|
122
122
|
}
|
|
123
|
-
|
|
124
|
-
/** Words in a string. The run and the page count them the same way. */
|
|
125
|
-
export function countWords(text: string): number {
|
|
126
|
-
return text.split(/\s+/).filter(Boolean).length;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/** `m:ss` for the progress log — a byte offset means nothing to a reader. */
|
|
130
|
-
export function clock(ms: number): string {
|
|
131
|
-
const seconds = Math.max(0, Math.round(ms / 1000));
|
|
132
|
-
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
|
|
133
|
-
}
|
|
@@ -138,11 +138,11 @@
|
|
|
138
138
|
* what keeps that order a pure function of journaled values.
|
|
139
139
|
*/
|
|
140
140
|
|
|
141
|
+
import { mapConcurrent, readUpload, report, uploadInfo } from "@alexkroman1/aai/step";
|
|
141
142
|
import { throwFatalStepError } from "@alexkroman1/aai/step-errors";
|
|
142
|
-
import {
|
|
143
|
+
import { formatDuration, plural } from "@alexkroman1/aai/utils";
|
|
143
144
|
import { sleep } from "workflow";
|
|
144
145
|
import {
|
|
145
|
-
clock,
|
|
146
146
|
fatalOnUnsupported,
|
|
147
147
|
mergeTranscript,
|
|
148
148
|
type SegmentTranscript,
|
|
@@ -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. */
|
|
@@ -326,9 +324,8 @@ export async function planStreamed(id: string): Promise<StreamPlan> {
|
|
|
326
324
|
}
|
|
327
325
|
const segments = fatalOnUnsupported(() => planSegments(format));
|
|
328
326
|
await report(
|
|
329
|
-
`Planned ${
|
|
330
|
-
segments.length
|
|
331
|
-
} while it uploads.`,
|
|
327
|
+
`Planned ${formatDuration(segments.at(-1)?.endMs ?? 0)} of audio as ` +
|
|
328
|
+
`${segments.length} ${plural(segments.length, "segment")} while it uploads.`,
|
|
332
329
|
);
|
|
333
330
|
return { format, segments };
|
|
334
331
|
}
|
|
@@ -339,8 +336,12 @@ export async function planStreamed(id: string): Promise<StreamPlan> {
|
|
|
339
336
|
* Not `plan.segments.length`: the plan came from the header's declared length, and a
|
|
340
337
|
* recording that came up short has segments that start past the end of the file.
|
|
341
338
|
* Counting those would leave the run waiting for audio nobody is going to send.
|
|
339
|
+
*
|
|
340
|
+
* Exported for its spec. It is the one piece of this flow's exit condition that is
|
|
341
|
+
* a pure function of journaled values, so it is the one a test can pin — and the
|
|
342
|
+
* failure it guards is a run that never ends rather than one that fails.
|
|
342
343
|
*/
|
|
343
|
-
function expectedSegments(plan: StreamPlan, size: number): number {
|
|
344
|
+
export function expectedSegments(plan: StreamPlan, size: number): number {
|
|
344
345
|
return plan.segments.filter((segment) => segment.start < size).length;
|
|
345
346
|
}
|
|
346
347
|
|
|
@@ -1,35 +1,24 @@
|
|
|
1
1
|
// Copyright 2026 the AAI authors. MIT license.
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* What both SYNC flows share, now that the endpoint itself is the SDK's.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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/step` — 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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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 {
|
|
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 { stepTranscribeSyncClassified } from "@alexkroman1/aai/step-errors";
|
|
33
22
|
|
|
34
23
|
/**
|
|
35
24
|
* Time one transcription, so the progress log carries LATENCY.
|
|
@@ -65,6 +54,15 @@ export function elapsed(ms: number): string {
|
|
|
65
54
|
* callers arrive at that differently: one re-attaches a header to a window it
|
|
66
55
|
* read, the other is handed parts that already carry one.
|
|
67
56
|
*
|
|
57
|
+
* `stepTranscribeSyncClassified` — the SDK's own `stepTranscribeSync` plus
|
|
58
|
+
* `throwStepError`, and nothing else — is the whole of what this adds to the SDK
|
|
59
|
+
* call, and it is 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
|
|
78
|
-
|
|
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 stepTranscribeSyncClassified(bytes, { filename, label });
|
|
76
|
+
return text;
|
|
145
77
|
}
|