@alexkroman1/aai-cli 6.11.0 → 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 +40 -19
- package/dist/scaffold/package.json +5 -3
- package/dist/scaffold/server.mjs +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 +57 -204
- package/dist/templates/call-audit/agent.ts +32 -19
- package/dist/templates/call-audit/client.tsx +17 -54
- package/dist/templates/call-audit/workflows/audit.ts +11 -18
- package/dist/templates/call-audit/workflows/ingest.ts +106 -114
- package/dist/templates/call-audit/workflows/media.ts +2 -12
- package/dist/templates/call-audit/workflows/summarize.ts +47 -52
- package/dist/templates/call-audit/workflows/sync-api.ts +7 -7
- 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 +97 -94
- 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 +36 -40
- 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 +68 -97
- package/dist/templates/spoken-summary/agent.ts +30 -17
- package/dist/templates/spoken-summary/client.tsx +10 -50
- package/dist/templates/spoken-summary/workflows/summarize.ts +17 -22
- package/dist/templates/spoken-summary/workflows/transcribe.ts +19 -26
- 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 +94 -194
- package/dist/templates/transcription-workflow/agent.ts +1 -1
- package/dist/templates/transcription-workflow/client.tsx +17 -42
- package/dist/templates/transcription-workflow/workflows/batch.ts +19 -22
- package/dist/templates/transcription-workflow/workflows/normalize.ts +90 -186
- package/dist/templates/transcription-workflow/workflows/stitch.ts +0 -11
- package/dist/templates/transcription-workflow/workflows/stream.ts +4 -5
- package/dist/templates/transcription-workflow/workflows/sync-api.ts +6 -6
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +32 -26
- package/dist/templates/transcription-workflow/workflows/wav.ts +0 -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/call-audit/workflows/temp-media.ts +0 -138
- 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
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* ## The endpoint is the SDK's; the STEPS are ours
|
|
27
27
|
*
|
|
28
28
|
* `stepTranscribeUpload` / `stepTranscribeSubmit` / `stepTranscribePoll` on
|
|
29
|
-
* `@alexkroman1/aai/
|
|
29
|
+
* `@alexkroman1/aai/step` own the URL, the raw-key auth, the windowed streaming
|
|
30
30
|
* upload, the PLURAL `speech_models` field and the failure classification. This file
|
|
31
31
|
* used to spell all of that out, and so did `spoken-summary` — the same ~200 lines
|
|
32
32
|
* twice, reworded, identical in behaviour, and drifting apart at the edges.
|
|
@@ -54,17 +54,15 @@
|
|
|
54
54
|
* request rather than two and the value journaled by the last poll IS the result.
|
|
55
55
|
*/
|
|
56
56
|
|
|
57
|
-
import {
|
|
57
|
+
import { report, TRANSCRIBE_API, uploadInfo } from "@alexkroman1/aai/step";
|
|
58
58
|
import {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
uploadInfo,
|
|
65
|
-
} from "@alexkroman1/aai/utils";
|
|
59
|
+
stepTranscribePollClassified,
|
|
60
|
+
stepTranscribeSubmitClassified,
|
|
61
|
+
stepTranscribeUploadClassified,
|
|
62
|
+
} from "@alexkroman1/aai/step-errors";
|
|
63
|
+
import { countWords, formatBytes } from "@alexkroman1/aai/utils";
|
|
66
64
|
import { sleep } from "workflow";
|
|
67
|
-
import {
|
|
65
|
+
import { startClock, type Transcript } from "./transcribe.ts";
|
|
68
66
|
|
|
69
67
|
/** How long between polls of a submitted job. */
|
|
70
68
|
const POLL_INTERVAL = "10s";
|
|
@@ -118,16 +116,20 @@ export async function transcribeBatchFlow(input: { recording: string }): Promise
|
|
|
118
116
|
* far smaller: if it expires before the next step runs, the run fails and a fresh one
|
|
119
117
|
* re-uploads — which is what would have happened anyway, once, instead of five times.
|
|
120
118
|
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
119
|
+
* The `Classified` callers on `@alexkroman1/aai/step-errors` are the SDK's own
|
|
120
|
+
* `stepTranscribe*` plus `throwStepError` and nothing else, which is what turns the
|
|
121
|
+
* SDK's `TranscribeError` into the DevKit's verdict: a missing key and a 400 stop, a
|
|
122
|
+
* 429 waits as long as the service asked. Every step here ends the same way for the
|
|
123
|
+
* same reason.
|
|
124
124
|
*/
|
|
125
125
|
export async function uploadToProvider(uploadId: string): Promise<{ audioUrl: string }> {
|
|
126
126
|
"use step";
|
|
127
127
|
|
|
128
128
|
const stored = await uploadInfo(uploadId);
|
|
129
|
-
await report(
|
|
130
|
-
|
|
129
|
+
await report(
|
|
130
|
+
`Uploading ${stored.name || uploadId} (${formatBytes(stored.size)}) to the async API.`,
|
|
131
|
+
);
|
|
132
|
+
return await stepTranscribeUploadClassified(uploadId);
|
|
131
133
|
}
|
|
132
134
|
|
|
133
135
|
/** Retries beyond the default 3: an upload is the one call here worth another attempt. */
|
|
@@ -137,7 +139,7 @@ uploadToProvider.maxRetries = 5;
|
|
|
137
139
|
export async function createJob(audioUrl: string): Promise<{ id: string }> {
|
|
138
140
|
"use step";
|
|
139
141
|
|
|
140
|
-
const job = await
|
|
142
|
+
const job = await stepTranscribeSubmitClassified(audioUrl);
|
|
141
143
|
await report(`Submitted — job ${job.id}.`);
|
|
142
144
|
return job;
|
|
143
145
|
}
|
|
@@ -157,7 +159,7 @@ export async function pollTranscript(
|
|
|
157
159
|
): Promise<{ done: false } | { done: true; transcript: Transcript }> {
|
|
158
160
|
"use step";
|
|
159
161
|
|
|
160
|
-
const progress = await
|
|
162
|
+
const progress = await stepTranscribePollClassified(id);
|
|
161
163
|
if (!progress.done) {
|
|
162
164
|
await report(`Transcript ${id} is ${progress.status}.`);
|
|
163
165
|
return { done: false };
|
|
@@ -185,8 +187,3 @@ export async function pollTranscript(
|
|
|
185
187
|
},
|
|
186
188
|
};
|
|
187
189
|
}
|
|
188
|
-
|
|
189
|
-
/** A size a person can read, because the number that matters is the scale. */
|
|
190
|
-
function mb(bytes: number): string {
|
|
191
|
-
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
192
|
-
}
|
|
@@ -39,39 +39,33 @@
|
|
|
39
39
|
*
|
|
40
40
|
* ## File → file, not bytes → bytes
|
|
41
41
|
*
|
|
42
|
-
* `transcodeToWav(bytes)` is one line and is the wrong call here,
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* tidy. A step is journaled by its RETURN VALUE and may be dispatched into a
|
|
62
|
-
* different process than its neighbours, so a path in a return value is a path
|
|
63
|
-
* that is replayed after the file behind it is gone. What crosses the boundary
|
|
64
|
-
* is an upload ID; the temp directory is created and removed inside the step
|
|
65
|
-
* that uses it.
|
|
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.
|
|
66
61
|
*/
|
|
67
62
|
|
|
68
|
-
import { mkdtemp, open, rm } from "node:fs/promises";
|
|
69
|
-
import { tmpdir } from "node:os";
|
|
70
63
|
import { basename, extname, join } from "node:path";
|
|
71
|
-
import {
|
|
72
|
-
import {
|
|
73
|
-
import {
|
|
74
|
-
import {
|
|
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";
|
|
75
69
|
import { HEADER_PROBE_BYTES, parseWav, UnsupportedRecordingError } from "./wav.ts";
|
|
76
70
|
|
|
77
71
|
/**
|
|
@@ -97,16 +91,6 @@ export const NORMALIZED_SAMPLE_RATE = 16_000;
|
|
|
97
91
|
*/
|
|
98
92
|
export const NORMALIZED_CHANNELS = 1;
|
|
99
93
|
|
|
100
|
-
/**
|
|
101
|
-
* Bytes moved per `readUpload` while materializing, and per write while storing.
|
|
102
|
-
*
|
|
103
|
-
* 8 MiB is large enough that a two-hour recording is a few hundred round trips
|
|
104
|
-
* rather than tens of thousands, and small enough that the step's resident set
|
|
105
|
-
* is a constant that does not depend on the recording. The number this must NOT
|
|
106
|
-
* be is "the whole file", which is the shape every first draft of this step has.
|
|
107
|
-
*/
|
|
108
|
-
const WINDOW_BYTES = 8 * 1024 * 1024;
|
|
109
|
-
|
|
110
94
|
/**
|
|
111
95
|
* How long a conversion may run before it is killed.
|
|
112
96
|
*
|
|
@@ -114,7 +98,7 @@ const WINDOW_BYTES = 8 * 1024 * 1024;
|
|
|
114
98
|
* realtime by two orders of magnitude, so a two-hour recording is under a
|
|
115
99
|
* minute — and the reason for a bound at all is a file that makes a decoder
|
|
116
100
|
* pathological rather than one that is merely long. A `timeout` is retryable
|
|
117
|
-
* and an `exit` is not;
|
|
101
|
+
* and an `exit` is not; `throwFfmpegStepError` decides.
|
|
118
102
|
*/
|
|
119
103
|
const CONVERT_TIMEOUT_MS = 15 * 60_000;
|
|
120
104
|
|
|
@@ -158,72 +142,78 @@ export async function normalizeRecording(uploadId: string): Promise<NormalizedRe
|
|
|
158
142
|
// stuck. It is also the line that distinguishes "this file needs converting" from
|
|
159
143
|
// the fast path above.
|
|
160
144
|
await report(
|
|
161
|
-
`Converting ${stored.name || uploadId} (${
|
|
145
|
+
`Converting ${stored.name || uploadId} (${formatBytes(stored.size)}) — not a WAV we can cut.`,
|
|
162
146
|
);
|
|
163
147
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
+
});
|
|
210
199
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
|
|
219
|
-
|
|
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
|
+
);
|
|
220
210
|
}
|
|
221
211
|
|
|
222
212
|
/**
|
|
223
213
|
* Retries beyond the default 3.
|
|
224
214
|
*
|
|
225
215
|
* Not because a conversion is flaky — a corrupt file fails identically forever,
|
|
226
|
-
* and
|
|
216
|
+
* and `throwFfmpegStepError` is what stops the DevKit retrying that. It is the
|
|
227
217
|
* two I/O halves that are worth another attempt: this step reads a whole
|
|
228
218
|
* recording out of the store and writes a whole one back, and either can lose a
|
|
229
219
|
* connection on a file this size.
|
|
@@ -248,96 +238,10 @@ export function cuttable(head: Uint8Array, totalBytes: number): boolean {
|
|
|
248
238
|
}
|
|
249
239
|
}
|
|
250
240
|
|
|
251
|
-
/**
|
|
252
|
-
* Turn an ffmpeg failure into the DevKit's verdict.
|
|
253
|
-
*
|
|
254
|
-
* The whole reason `FfmpegError.kind` exists, used the way it was meant to be:
|
|
255
|
-
* an `exit` is ffmpeg having read the file and refused it, so every retry
|
|
256
|
-
* re-reads the same bytes and reaches the same conclusion while burning the
|
|
257
|
-
* budget a real transient needs. A `timeout` or an `aborted` is worth another
|
|
258
|
-
* attempt, and a `missing-binary` is `aai dev` on a laptop with no ffmpeg —
|
|
259
|
-
* fatal, and already carrying the install instructions in its message.
|
|
260
|
-
*
|
|
261
|
-
* **The retryable arm goes through `throwStepError` even though it classifies
|
|
262
|
-
* nothing**, and that is deliberate rather than a leftover. `toStepError` reaches
|
|
263
|
-
* a verdict from a `Response` or from an SDK error that already carries one; an
|
|
264
|
-
* `FfmpegError` is neither, so it is rethrown UNCHANGED — which the DevKit treats
|
|
265
|
-
* as retryable by default, the outcome this arm wants. Writing
|
|
266
|
-
* `new RetryableError(...)` here instead would replace ffmpeg's own message and
|
|
267
|
-
* its `argv` with a sentence, and the argv is the thing you paste into a shell.
|
|
268
|
-
* So the call reads as the decision it is: everything this function does not
|
|
269
|
-
* declare terminal keeps its retries.
|
|
270
|
-
*
|
|
271
|
-
* Exported for its spec. It is the one decision in this file a unit test can
|
|
272
|
-
* reach — everything around it spawns a subprocess — and it is also the one worth
|
|
273
|
-
* reaching: getting it backwards means either five re-reads of a corrupt file or
|
|
274
|
-
* no second attempt at a conversion that was merely slow.
|
|
275
|
-
*/
|
|
276
|
-
export function classifyFfmpeg(err: unknown): never {
|
|
277
|
-
if (isFfmpegError(err) && (err.kind === "timeout" || err.kind === "aborted")) {
|
|
278
|
-
return throwStepError(err);
|
|
279
|
-
}
|
|
280
|
-
return throwFatalStepError(err);
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
/**
|
|
284
|
-
* Write an upload to a local path, a window at a time.
|
|
285
|
-
*
|
|
286
|
-
* The `readUpload` window is the same primitive `transcribeSegment` cuts with;
|
|
287
|
-
* what differs is only that this one walks the whole file in order. A `for` loop
|
|
288
|
-
* rather than a fan-out deliberately — the bytes land in one file at one offset
|
|
289
|
-
* each, so concurrency buys nothing here and costs the memory the windows are
|
|
290
|
-
* there to bound.
|
|
291
|
-
*/
|
|
292
|
-
async function materialize(uploadId: string, size: number, path: string): Promise<void> {
|
|
293
|
-
const handle = await open(path, "w");
|
|
294
|
-
try {
|
|
295
|
-
for (let at = 0; at < size; at += WINDOW_BYTES) {
|
|
296
|
-
const slice = await readUpload(uploadId, {
|
|
297
|
-
start: at,
|
|
298
|
-
end: Math.min(at + WINDOW_BYTES, size),
|
|
299
|
-
});
|
|
300
|
-
await handle.write(slice.bytes);
|
|
301
|
-
}
|
|
302
|
-
} finally {
|
|
303
|
-
await handle.close();
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
/**
|
|
308
|
-
* A local file as the stream `writeUpload` takes.
|
|
309
|
-
*
|
|
310
|
-
* A generator rather than `readFile`, for the reason the windows exist: the
|
|
311
|
-
* converted WAV is the largest thing this step touches, and handing the store an
|
|
312
|
-
* `AsyncIterable` is what keeps it off the heap.
|
|
313
|
-
*
|
|
314
|
-
* The `.slice()` is load-bearing. One buffer is reused across reads, so yielding
|
|
315
|
-
* a view of it hands the consumer memory the next read overwrites — a bug whose
|
|
316
|
-
* symptom is a stored file made of the LAST chunk repeated, and which does not
|
|
317
|
-
* reproduce whenever the consumer happens to copy before the next iteration.
|
|
318
|
-
*/
|
|
319
|
-
async function* chunks(path: string): AsyncIterable<Uint8Array> {
|
|
320
|
-
const handle = await open(path, "r");
|
|
321
|
-
try {
|
|
322
|
-
const buffer = new Uint8Array(WINDOW_BYTES);
|
|
323
|
-
for (;;) {
|
|
324
|
-
const { bytesRead } = await handle.read(buffer, 0, buffer.length, null);
|
|
325
|
-
if (bytesRead === 0) return;
|
|
326
|
-
yield buffer.subarray(0, bytesRead).slice();
|
|
327
|
-
}
|
|
328
|
-
} finally {
|
|
329
|
-
await handle.close();
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
|
|
333
241
|
/** `41:20 of aac`, or as much of that as ffprobe would say. */
|
|
334
242
|
function describeSource(codec: string | undefined, durationSec: number | undefined): string {
|
|
335
|
-
const length =
|
|
243
|
+
const length =
|
|
244
|
+
durationSec === undefined ? undefined : formatDuration(Math.round(durationSec * 1000));
|
|
336
245
|
if (length !== undefined && codec !== undefined) return `${length} of ${codec}`;
|
|
337
246
|
return length ?? codec ?? "the recording";
|
|
338
247
|
}
|
|
339
|
-
|
|
340
|
-
/** A size a person can read, because the number that matters is the scale. */
|
|
341
|
-
function mb(bytes: number): string {
|
|
342
|
-
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
343
|
-
}
|
|
@@ -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,
|
|
@@ -324,9 +324,8 @@ export async function planStreamed(id: string): Promise<StreamPlan> {
|
|
|
324
324
|
}
|
|
325
325
|
const segments = fatalOnUnsupported(() => planSegments(format));
|
|
326
326
|
await report(
|
|
327
|
-
`Planned ${
|
|
328
|
-
segments.length
|
|
329
|
-
} while it uploads.`,
|
|
327
|
+
`Planned ${formatDuration(segments.at(-1)?.endMs ?? 0)} of audio as ` +
|
|
328
|
+
`${segments.length} ${plural(segments.length, "segment")} while it uploads.`,
|
|
330
329
|
);
|
|
331
330
|
return { format, segments };
|
|
332
331
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* This module used to BE the endpoint: the URL, the model header, the raw-key auth,
|
|
6
6
|
* the deadline, the multipart shape and the three-way failure classification. All of
|
|
7
|
-
* that is `stepTranscribeSync` on `@alexkroman1/aai/
|
|
7
|
+
* that is `stepTranscribeSync` on `@alexkroman1/aai/step` — the same request, with
|
|
8
8
|
* `transcription-workflow`'s own hard-won details (the unprefixed key, the
|
|
9
9
|
* `X-AAI-Model` header, `stepFetch` rather than `fetch` so a fan-out's rate limit
|
|
10
10
|
* arrives as a status rather than as a stream reset) carried into it.
|
|
@@ -18,8 +18,7 @@
|
|
|
18
18
|
* the same shape). It is called FROM steps, so it inherits their environment.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import {
|
|
22
|
-
import { stepTranscribeSync } from "@alexkroman1/aai/utils";
|
|
21
|
+
import { stepTranscribeSyncClassified } from "@alexkroman1/aai/step-errors";
|
|
23
22
|
|
|
24
23
|
/**
|
|
25
24
|
* Time one transcription, so the progress log carries LATENCY.
|
|
@@ -55,8 +54,9 @@ export function elapsed(ms: number): string {
|
|
|
55
54
|
* callers arrive at that differently: one re-attaches a header to a window it
|
|
56
55
|
* read, the other is handed parts that already carry one.
|
|
57
56
|
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
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
60
|
* something that will answer the same way, a bare `RetryableError` retries in ONE
|
|
61
61
|
* SECOND (that class's own default), and a `RetryableError` carrying `retryAfter`
|
|
62
62
|
* waits exactly as long as the far side asked. The last matters here because a whole
|
|
@@ -72,6 +72,6 @@ export async function transcribeWav(
|
|
|
72
72
|
filename: string,
|
|
73
73
|
label: string,
|
|
74
74
|
): Promise<string> {
|
|
75
|
-
const { text } = await
|
|
75
|
+
const { text } = await stepTranscribeSyncClassified(bytes, { filename, label });
|
|
76
76
|
return text;
|
|
77
77
|
}
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
* ## Three properties this leans on
|
|
42
42
|
*
|
|
43
43
|
* - **A step can read the agent's env now.** `stepEnv`/`requireStepEnv`
|
|
44
|
-
* (`@alexkroman1/aai/
|
|
44
|
+
* (`@alexkroman1/aai/step`) is what makes any of this real: a step is
|
|
45
45
|
* dispatched separately from the agent bundle and is handed no `ToolContext`,
|
|
46
46
|
* so before that seam existed no step anywhere could authenticate an outbound
|
|
47
47
|
* call, and every workflow template's I/O was a fixture saying so.
|
|
@@ -63,16 +63,18 @@
|
|
|
63
63
|
* `output` exists only when the last segment does.
|
|
64
64
|
*/
|
|
65
65
|
|
|
66
|
+
import {
|
|
67
|
+
emit,
|
|
68
|
+
encodeWav,
|
|
69
|
+
mapConcurrent,
|
|
70
|
+
readUpload,
|
|
71
|
+
report,
|
|
72
|
+
uploadInfo,
|
|
73
|
+
} from "@alexkroman1/aai/step";
|
|
66
74
|
import { throwFatalStepError } from "@alexkroman1/aai/step-errors";
|
|
67
|
-
import {
|
|
75
|
+
import { countWords, formatDuration, plural } from "@alexkroman1/aai/utils";
|
|
68
76
|
import { normalizeRecording } from "./normalize.ts";
|
|
69
|
-
import {
|
|
70
|
-
clock,
|
|
71
|
-
countWords,
|
|
72
|
-
stitchTranscript,
|
|
73
|
-
TRANSCRIPT_STREAM,
|
|
74
|
-
type TranscriptChunk,
|
|
75
|
-
} from "./stitch.ts";
|
|
77
|
+
import { stitchTranscript, TRANSCRIPT_STREAM, type TranscriptChunk } from "./stitch.ts";
|
|
76
78
|
import { elapsed, timed, transcribeWav } from "./sync-api.ts";
|
|
77
79
|
import {
|
|
78
80
|
bytesPerSecond,
|
|
@@ -84,7 +86,6 @@ import {
|
|
|
84
86
|
type Segment,
|
|
85
87
|
UnsupportedRecordingError,
|
|
86
88
|
type WavFormat,
|
|
87
|
-
wavWithHeader,
|
|
88
89
|
} from "./wav.ts";
|
|
89
90
|
|
|
90
91
|
/**
|
|
@@ -281,7 +282,7 @@ export async function splitRecording(uploadId: string): Promise<{
|
|
|
281
282
|
const durationMs = segments.at(-1)?.endMs ?? 0;
|
|
282
283
|
|
|
283
284
|
await report(
|
|
284
|
-
`Split ${
|
|
285
|
+
`Split ${formatDuration(durationMs)} of audio into ${segments.length} ${plural(segments.length, "segment")}.`,
|
|
285
286
|
);
|
|
286
287
|
return { format, segments, durationMs };
|
|
287
288
|
}
|
|
@@ -308,7 +309,7 @@ export async function transcribeSegment(
|
|
|
308
309
|
// calls together, so their lines interleave by completion — the page renders a
|
|
309
310
|
// log, not a sequence, and `segment.index` is what puts the TRANSCRIPT back in
|
|
310
311
|
// order.
|
|
311
|
-
await report(`Transcribing ${
|
|
312
|
+
await report(`Transcribing ${formatDuration(segment.startMs)}–${formatDuration(segment.endMs)}.`);
|
|
312
313
|
|
|
313
314
|
// `[start, end)`, the same half-open pair `planSegments` produced — the store
|
|
314
315
|
// owns the conversion to HTTP's inclusive range, so there is no `- 1` here to
|
|
@@ -321,20 +322,25 @@ export async function transcribeSegment(
|
|
|
321
322
|
// answers better — and getting it wrong is a whole transcript in the wrong
|
|
322
323
|
// language. Add one back only for a desk that really knows.
|
|
323
324
|
//
|
|
324
|
-
// `
|
|
325
|
+
// `encodeWav` is what makes a WINDOW decodable: the endpoint decodes each
|
|
325
326
|
// request independently, so a slice of the middle of a recording is a headerless
|
|
326
327
|
// tail until one is put back on it. The streaming flow needs no equivalent — its
|
|
327
|
-
// parts were cut with a header each.
|
|
328
|
+
// parts were cut with a header each. The header is the SDK's rather than this
|
|
329
|
+
// template's: a `WavFormat` is structurally a `PcmFormat`, and 22 lines of
|
|
330
|
+
// `DataView` writes with a comment about which of the two declared lengths a
|
|
331
|
+
// decoder trusts is not a thing worth a second copy of.
|
|
328
332
|
const { value: text, ms } = await timed(() =>
|
|
329
333
|
transcribeWav(
|
|
330
|
-
|
|
334
|
+
encodeWav(audio.bytes, format),
|
|
331
335
|
`segment-${segment.index}.wav`,
|
|
332
|
-
`Segment ${segment.index} (${
|
|
336
|
+
`Segment ${segment.index} (${formatDuration(segment.startMs)})`,
|
|
333
337
|
),
|
|
334
338
|
);
|
|
335
339
|
// The LATENCY, which is what says whether the concurrency bound or the endpoint
|
|
336
340
|
// is the thing limiting the run — see `timed`'s doc.
|
|
337
|
-
await report(
|
|
341
|
+
await report(
|
|
342
|
+
`Transcribed ${formatDuration(segment.startMs)}–${formatDuration(segment.endMs)} in ${elapsed(ms)}.`,
|
|
343
|
+
);
|
|
338
344
|
// And the WORDS, into their own stream, which is what makes this run's answer
|
|
339
345
|
// streamable rather than only its narration: the page stitches whatever has
|
|
340
346
|
// arrived and renders the transcript growing, minutes before `output` exists.
|
|
@@ -372,7 +378,7 @@ export async function mergeTranscript(
|
|
|
372
378
|
): Promise<Transcript> {
|
|
373
379
|
"use step";
|
|
374
380
|
|
|
375
|
-
await report(`Stitching ${parts.length}
|
|
381
|
+
await report(`Stitching ${parts.length} ${plural(parts.length, "segment")} together.`);
|
|
376
382
|
|
|
377
383
|
// `mapConcurrent` resolves in ITEM order however the calls settled, so this is
|
|
378
384
|
// already ordered — sorted anyway, because the merge is where an ordering
|
|
@@ -417,12 +423,13 @@ export async function startClock(): Promise<number> {
|
|
|
417
423
|
return Date.now();
|
|
418
424
|
}
|
|
419
425
|
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
//
|
|
426
|
+
// Re-exported rather than re-declared: `stream.ts` and `batch.ts` already import
|
|
427
|
+
// these from this module, and the split that let the PAGE stitch a partial
|
|
428
|
+
// transcript should not ripple through every flow. `clock` and `countWords` used
|
|
429
|
+
// to be in this list and are `formatDuration`/`countWords` on
|
|
430
|
+
// `@alexkroman1/aai/utils` now — a run narrates itself and the page renders the
|
|
431
|
+
// same run, so those two were a private copy of a formatter the SDK ships.
|
|
423
432
|
export {
|
|
424
|
-
clock,
|
|
425
|
-
countWords,
|
|
426
433
|
stitchChunks,
|
|
427
434
|
stitchTranscript,
|
|
428
435
|
TRANSCRIPT_STREAM,
|
|
@@ -434,9 +441,8 @@ export {
|
|
|
434
441
|
/**
|
|
435
442
|
* Run a `wav.ts` helper, turning its "cannot cut this" into a terminal failure.
|
|
436
443
|
*
|
|
437
|
-
* Exported
|
|
438
|
-
*
|
|
439
|
-
* byte.
|
|
444
|
+
* Exported because `stream.ts` plans with the same `wav.ts` helpers and owes the
|
|
445
|
+
* same classification, and it had this byte for byte.
|
|
440
446
|
*/
|
|
441
447
|
export function fatalOnUnsupported<T>(read: () => T): T {
|
|
442
448
|
try {
|