@alexkroman1/aai-cli 6.10.1 → 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
@@ -0,0 +1,158 @@
1
+ // Copyright 2026 the AAI authors. MIT license.
2
+ /**
3
+ * A WORKFLOW APP that audits a recorded call, with **ffmpeg in the pipeline on
4
+ * both sides of the model.**
5
+ *
6
+ * ```text
7
+ * any recording → levelled PCM → cut at the pauses → transcript
8
+ * ffmpeg ffmpeg sync STT
9
+ *
10
+ * → headline, risks, actions → a script → an MP3
11
+ * LLM Gateway schema TTS + ffmpeg
12
+ * ```
13
+ *
14
+ * ## Which template to read first
15
+ *
16
+ * This one is the deepest of the workflow apps, so it is the wrong place to start.
17
+ *
18
+ * - `link-digest` owns the SHAPE — `workflowApp()`, no session, no tools, a form
19
+ * that starts a run and a page that watches it.
20
+ * - `transcription-workflow` owns the FAN-OUT — why the sync transcription
21
+ * endpoint's 120-second cap forces one, and how a run survives dying on segment
22
+ * 27 of 60.
23
+ * - `spoken-summary` owns the audio ROUND TRIP — `stepSpeak`, `writeUpload`, and
24
+ * why a page needs `api.download` rather than a URL.
25
+ *
26
+ * **What this template adds is a decoder**, and everything downstream changes
27
+ * because of it. `workflows/audit.ts` carries the table comparing it against
28
+ * `transcription-workflow` row by row; the summary is that normalizing FIRST lets
29
+ * the desk cut a recording *in its pauses* instead of every 90 seconds, which
30
+ * deletes the segment overlap, the seam-matching stitcher, the WAV header parser
31
+ * and one of the two provider caps it had to plan against.
32
+ *
33
+ * ## Five ffmpeg jobs, and each one is a decision
34
+ *
35
+ * `workflows/media.ts` is where they are built, and it is the file to read: every
36
+ * argv is a pure function, so the argv is a value a spec asserts on rather than a
37
+ * string embedded in a step that spawns.
38
+ *
39
+ * | | what it does | why it is not obvious |
40
+ * | --- | --- | --- |
41
+ * | `ffprobe` | what the file WAS | on a temp file, because a pipe cannot seek an m4a's trailing index |
42
+ * | `loudnorm` pass 1 | measure five numbers | `-f null -`: decode everything, write nothing |
43
+ * | `loudnorm` pass 2 | apply them | one linear gain, so speech does not pump |
44
+ * | `silencedetect` | find every pause | same pass as above — a filter chain, not a second decode |
45
+ * | `libmp3lame` | master the summary | 4.3 MB of WAV becomes ~110 KB |
46
+ *
47
+ * The two analyses read their answers back by **different routes**, and that is the
48
+ * single most surprising thing in the template: loudness arrives on stderr (one
49
+ * block, printed last, so the SDK's capped stderr TAIL holds it) and the pauses
50
+ * arrive in a FILE (one event per pause, so a tail would silently drop the
51
+ * earliest ones and the desk would mis-cut only long recordings). `media.ts`'s
52
+ * module doc carries it.
53
+ *
54
+ * ## What it needs
55
+ *
56
+ * - **`ASSEMBLYAI_API_KEY` in the agent env** — `.env` under `aai dev`,
57
+ * `aai secret put ASSEMBLYAI_API_KEY` once deployed. `requiredEnv` below is what
58
+ * makes a deploy check for it rather than letting the first run find out. One key
59
+ * covers transcription, the model and the voice alike.
60
+ * - **Storage** (`aai storage enable`, Settings → Database in the studio, or
61
+ * `DATABASE_URL` under `aai dev`). REQUIRED, unlike most workflow apps: an
62
+ * upload's record is a row, and this desk both reads an upload and writes two.
63
+ * - **ffmpeg** — every deployed guest's image installs it (and `ffprobe` with it).
64
+ * Under `aai dev` it is whatever is on `PATH`, or what `AAI_FFMPEG_PATH` /
65
+ * `AAI_FFPROBE_PATH` name. That is the one place dev/prod parity is partial, so
66
+ * a missing binary is reported as an instruction rather than as
67
+ * `spawn ffmpeg ENOENT`.
68
+ *
69
+ * ## It is scriptable, which is the other half of having an API
70
+ *
71
+ * The page is one caller. Two requests do the same thing from a shell — upload,
72
+ * then start a run naming the id, with `wait` holding the request open:
73
+ *
74
+ * ```sh
75
+ * ID=$(curl -s -X POST "https://<your-agent>/workflows/uploads?name=call.m4a" \
76
+ * -H 'content-type: audio/mp4' --data-binary @call.m4a | jq -r .id)
77
+ *
78
+ * curl -X POST https://<your-agent>/workflows/runs \
79
+ * -H 'content-type: application/json' \
80
+ * -d "{\"workflow\":\"audit\",\"wait\":60000,\"input\":{\"recording\":\"$ID\"}}"
81
+ * ```
82
+ *
83
+ * The spoken audit comes back as an upload id in the run's output; the byte route
84
+ * (`GET /workflows/uploads/<id>`) takes the same bearer every other route does.
85
+ */
86
+
87
+ import { workflow, workflowApp } from "@alexkroman1/aai";
88
+ import { ASSEMBLYAI_TTS_DEFAULT_VOICE, ASSEMBLYAI_TTS_VOICES } from "@alexkroman1/aai/tts";
89
+ import { z } from "zod";
90
+ import { auditFlow } from "./workflows/audit.ts";
91
+
92
+ /**
93
+ * The voices the form offers.
94
+ *
95
+ * READ from the SDK's catalog rather than listed, because a wrong voice id is a
96
+ * SILENT failure — it is a free-form string the service rejects in band after the
97
+ * socket is open, so the synthesis simply produces nothing. Narrowed to the English
98
+ * ones because the audit is written in the transcript's language and the prompt does
99
+ * not translate; every voice in the catalog speaks exactly one.
100
+ */
101
+ const VOICES = Object.entries(ASSEMBLYAI_TTS_VOICES)
102
+ .filter(([, spec]) => spec.language === "en")
103
+ .map(([id]) => id);
104
+
105
+ /**
106
+ * The same list as a TUPLE, which is what `z.enum` takes.
107
+ *
108
+ * Destructured rather than cast: a `.map` produces an array, and
109
+ * `as [string, ...string[]]` would be a template teaching a cast. The default covers
110
+ * the empty case honestly — a catalog with no English voice falls back to the SDK's
111
+ * own default rather than rendering a picker with no options.
112
+ */
113
+ const [FIRST_VOICE = ASSEMBLYAI_TTS_DEFAULT_VOICE, ...OTHER_VOICES] = VOICES;
114
+
115
+ /**
116
+ * The declaration: schema, description, and the directive body.
117
+ *
118
+ * Exported so `WorkflowOutputOf<typeof audit>` names the output type in one place —
119
+ * including from `client.tsx`, where `import type` is erased and so bundles nothing
120
+ * server-side.
121
+ */
122
+ export const audit = workflow({
123
+ description: "Level a call recording, transcribe it at its pauses, and audit what was said",
124
+ input: z.object({
125
+ // A plain string, because an upload id is what the run really receives. What
126
+ // makes it a file picker rather than a text box is the `uploads` line below.
127
+ //
128
+ // "Anything" is not marketing: the first step hands the file to ffmpeg, so the
129
+ // accepted set is ffmpeg's rather than this template's — a video's audio track
130
+ // included, since the conversion drops the video.
131
+ recording: z.string().describe("Any recording — WAV, MP3, M4A, or a video's audio track"),
132
+ // An enum, so the form renders a SELECT rather than a text box — which is the
133
+ // whole reason the list is derived above rather than left free-form. Optional,
134
+ // so the SDK's own default voice applies when nobody chooses.
135
+ voice: z
136
+ .enum([FIRST_VOICE, ...OTHER_VOICES])
137
+ .optional()
138
+ .describe("Voice to read the audit in"),
139
+ }),
140
+ // The one line that makes the form take a file: `<WorkflowFields>` renders a
141
+ // picker for this property, `useWorkflowSubmit` stores the chosen file, and the
142
+ // ingest step reads it back with `readUpload`.
143
+ uploads: ["recording"],
144
+ run: auditFlow,
145
+ });
146
+
147
+ export default workflowApp({
148
+ name: "Call Audit",
149
+ workflows: { audit },
150
+ // Checked at deploy time, so a missing key is a warning naming it rather than a run
151
+ // that fails on its third step. A workflow app declares no providers, so this is the
152
+ // only thing that can name the credential its steps read.
153
+ //
154
+ // ffmpeg is NOT here and cannot be: `requiredEnv` checks the agent's environment,
155
+ // and a binary on `PATH` is not an environment variable. The deployed guest always
156
+ // has one; under `aai dev` the failure names its own remedy.
157
+ requiredEnv: ["ASSEMBLYAI_API_KEY"],
158
+ });
@@ -0,0 +1,235 @@
1
+ // Copyright 2026 the AAI authors. MIT license.
2
+ /**
3
+ * The page: a form, a progress log, the audit, and a player.
4
+ *
5
+ * `link-digest` shows these primitives raw, `transcription-workflow` shows the form
6
+ * layer in full, and `spoken-summary` shows how a page plays a file the RUN
7
+ * produced. None of that is restated here — this page is deliberately the least
8
+ * novel file in the template, because the subject is the pipeline.
9
+ *
10
+ * Two things it does add, and both are about being honest about the pipeline:
11
+ *
12
+ * - **The PIPELINE panel.** A reader cannot tell from a transcript whether the desk
13
+ * cut it in the pauses or fell back to cutting by arithmetic, and that difference
14
+ * is exactly what explains a mangled word at a seam. So `blindCuts` is rendered
15
+ * rather than hidden, alongside what the recording measured before levelling.
16
+ * - **`api.download`, not a URL.** The run's output carries an upload id. The
17
+ * obvious `<audio src={`/workflows/uploads/${id}`}>` is wrong in a way that only
18
+ * shows up after a deploy: the byte route takes the same `Authorization` header
19
+ * every other route does, and neither `<audio src>` nor `<a href>` can send one.
20
+ * So a page built on a URL works against `aai dev`, where there is no token, and
21
+ * 401s the moment the agent has one.
22
+ */
23
+
24
+ import "@alexkroman1/aai-ui/styles.css";
25
+ // ERASED at build time, so naming the agent's own type costs the browser bundle
26
+ // nothing — and it is what stops this file restating a shape `workflows/audit.ts`
27
+ // already declares.
28
+ import type { WorkflowOutputOf } from "@alexkroman1/aai";
29
+ import {
30
+ createWorkflowApi,
31
+ Form,
32
+ page,
33
+ SubmitButton,
34
+ UploadProgressBar,
35
+ useWorkflowSubmit,
36
+ WorkflowFields,
37
+ WorkflowProgress,
38
+ } from "@alexkroman1/aai-ui";
39
+ import { useEffect, useState } from "react";
40
+ import type { audit } from "./agent.ts";
41
+
42
+ /** What a completed run reports, derived from the workflow rather than restated. */
43
+ type Audit = WorkflowOutputOf<typeof audit>;
44
+
45
+ /**
46
+ * The workflow's name, as a page starts a run by one.
47
+ *
48
+ * A rename in `agent.ts` is a runtime 400 rather than a compile error, which is why
49
+ * `agent.test.ts` pins this string.
50
+ */
51
+ const WORKFLOW = "audit";
52
+
53
+ /**
54
+ * Hoisted out of the component deliberately.
55
+ *
56
+ * The hooks hold the client in a ref precisely so a fresh object per render cannot
57
+ * restart their watch, but building one in render is still a new `fetch` closure
58
+ * every time and reads as though it were free.
59
+ */
60
+ const api = createWorkflowApi();
61
+
62
+ /** `4:09`, from the milliseconds a run reports. */
63
+ function duration(ms: number): string {
64
+ const total = Math.round(ms / 1000);
65
+ const minutes = Math.floor(total / 60);
66
+ return `${minutes}:${String(total % 60).padStart(2, "0")}`;
67
+ }
68
+
69
+ /**
70
+ * The finished run's audio, as something the browser will play.
71
+ *
72
+ * A hook rather than four lines in the component because the CLEANUP is the part
73
+ * worth keeping in one place: an object URL pins its blob for the life of the
74
+ * document, so it is revoked when the id changes and when the page goes away. The
75
+ * `cancelled` flag covers the other half — a second run settling while the first
76
+ * download is still in flight would otherwise set state from the stale one.
77
+ */
78
+ function useAudioUrl(uploadId: string | undefined): { url?: string; error?: string } {
79
+ const [state, setState] = useState<{ url?: string; error?: string }>({});
80
+
81
+ useEffect(() => {
82
+ if (uploadId === undefined) {
83
+ setState({});
84
+ return;
85
+ }
86
+ let cancelled = false;
87
+ let objectUrl: string | undefined;
88
+ api
89
+ .download(uploadId)
90
+ .then((blob) => {
91
+ if (cancelled) return;
92
+ objectUrl = URL.createObjectURL(blob);
93
+ setState({ url: objectUrl });
94
+ })
95
+ .catch((err: unknown) => {
96
+ if (!cancelled) setState({ error: err instanceof Error ? err.message : String(err) });
97
+ });
98
+ return () => {
99
+ cancelled = true;
100
+ if (objectUrl !== undefined) URL.revokeObjectURL(objectUrl);
101
+ };
102
+ }, [uploadId]);
103
+
104
+ return state;
105
+ }
106
+
107
+ /** One labelled number in the pipeline panel. */
108
+ function Stat({ label, value }: { label: string; value: string }) {
109
+ return (
110
+ <div className="flex flex-col">
111
+ <dt className="text-xs uppercase tracking-wide opacity-60">{label}</dt>
112
+ <dd className="text-sm">{value}</dd>
113
+ </div>
114
+ );
115
+ }
116
+
117
+ /** A list that renders nothing rather than an empty box — see `risks` in the schema. */
118
+ function Findings({ title, items }: { title: string; items: string[] }) {
119
+ if (items.length === 0) return null;
120
+ return (
121
+ <section className="flex flex-col gap-1">
122
+ <h3 className="text-sm font-medium opacity-70">{title}</h3>
123
+ <ul className="flex list-disc flex-col gap-1 pl-5">
124
+ {items.map((item) => (
125
+ <li key={item}>{item}</li>
126
+ ))}
127
+ </ul>
128
+ </section>
129
+ );
130
+ }
131
+
132
+ export function App() {
133
+ // The generic is what makes `run.status === "completed"` narrow to a TYPED
134
+ // `run.output` instead of `unknown`.
135
+ const { submit, run, pending, upload, pauseUpload, resumeUpload, error } =
136
+ useWorkflowSubmit<Audit>(WORKFLOW, { api });
137
+ const output = run?.status === "completed" ? run.output : undefined;
138
+ const audio = useAudioUrl(output?.audio);
139
+
140
+ return (
141
+ <main className="mx-auto flex max-w-3xl flex-col gap-6 p-8">
142
+ <header className="flex flex-col gap-1">
143
+ <h1 className="text-2xl font-medium">Call Audit</h1>
144
+ <p className="text-sm opacity-70">
145
+ Upload a call recording — any format. It comes back levelled, transcribed at its pauses,
146
+ and audited for risks and actions.
147
+ </p>
148
+ </header>
149
+
150
+ <Form onSubmit={submit} error={error} className="flex flex-col gap-4">
151
+ {/* Every control, from the workflow's own input schema. See the module doc. */}
152
+ <WorkflowFields workflow={WORKFLOW} />
153
+ <SubmitButton pending={pending} pendingLabel="Auditing…">
154
+ Audit the call
155
+ </SubmitButton>
156
+ </Form>
157
+
158
+ {/* The upload is its own wait, and the one nothing else can describe: the run
159
+ does not EXIST until the bytes are in, so there is no run id and nothing
160
+ for `<WorkflowProgress>` to read. */}
161
+ <UploadProgressBar upload={upload} onPause={pauseUpload} onResume={resumeUpload} />
162
+
163
+ {/* What the run itself says, from `report()` in the workflow's steps — which
164
+ for this template is the ffmpeg narration: what the file was, what it
165
+ measured, how many pauses were found. */}
166
+ <WorkflowProgress runId={run?.runId} api={api} />
167
+
168
+ {run?.status === "failed" && <p className="text-red-600">That one failed: {run.error}</p>}
169
+
170
+ {output !== undefined && (
171
+ <article className="flex flex-col gap-6">
172
+ <div className="flex flex-col gap-1">
173
+ <h2 className="text-xl">{output.headline}</h2>
174
+ <p className="text-sm opacity-70">
175
+ {output.source} · {duration(output.durationMs)} · {output.words} words
176
+ </p>
177
+ </div>
178
+
179
+ {/* What the pipeline did, which is this template's subject. Rendered rather
180
+ than logged because `blindCuts` is the one number that explains a bad
181
+ seam, and a reader has no other way to know. */}
182
+ <dl className="grid grid-cols-2 gap-3 rounded border border-current/10 p-4 sm:grid-cols-3">
183
+ <Stat label="Source codec" value={output.codec} />
184
+ <Stat label="Loudness in" value={`${output.loudnessBefore} LUFS`} />
185
+ <Stat label="Speech" value={`${output.speechPercent}%`} />
186
+ <Stat label="Segments" value={String(output.segments)} />
187
+ <Stat
188
+ label="Cut in speech"
189
+ value={output.blindCuts === 0 ? "none" : String(output.blindCuts)}
190
+ />
191
+ <Stat label="Run time" value={duration(output.elapsedMs)} />
192
+ </dl>
193
+
194
+ <Findings title="Risks" items={output.risks} />
195
+ <Findings title="Actions" items={output.actions} />
196
+
197
+ <section className="flex flex-col gap-2">
198
+ <h3 className="text-sm font-medium opacity-70">
199
+ Read aloud · {duration(output.audioDurationMs)} ·{" "}
200
+ {Math.round(output.audioBytes / 1024)} KB
201
+ </h3>
202
+ {audio.error !== undefined && (
203
+ <p className="text-red-600">Could not load the audio: {audio.error}</p>
204
+ )}
205
+ {audio.url !== undefined && (
206
+ <>
207
+ {/* No `<track>`, and that is a judgement rather than an
208
+ oversight: the spoken text is rendered in full immediately
209
+ below this player, which is the same information a caption
210
+ track would carry. `spoken-summary` serves a one-cue WebVTT
211
+ data URL instead — worth reading for how, if a real track is
212
+ what a page needs. */}
213
+ <audio controls src={audio.url} className="w-full" />
214
+ {/* `download` works on an object URL because the bytes are already in
215
+ the tab; it is the href that could not carry the agent's bearer,
216
+ not the attribute. */}
217
+ <a href={audio.url} download="audit.mp3" className="text-sm underline">
218
+ Download audit.mp3
219
+ </a>
220
+ </>
221
+ )}
222
+ <p className="text-sm opacity-70">{output.spoken}</p>
223
+ </section>
224
+
225
+ <details className="text-sm">
226
+ <summary className="cursor-pointer opacity-70">Transcript</summary>
227
+ <p className="mt-2 whitespace-pre-wrap">{output.transcript}</p>
228
+ </details>
229
+ </article>
230
+ )}
231
+ </main>
232
+ );
233
+ }
234
+
235
+ page({ name: "Call Audit", component: App });