@alexkroman1/aai-cli 6.10.0 → 6.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/dist/scaffold/CLAUDE.md +58 -0
  2. package/dist/scaffold/package.json +3 -3
  3. package/dist/scaffold/server.mjs +12 -3
  4. package/dist/scaffold/vite.config.ts +1 -1
  5. package/dist/templates/call-audit/agent.test.ts +965 -0
  6. package/dist/templates/call-audit/agent.ts +158 -0
  7. package/dist/templates/call-audit/client.tsx +235 -0
  8. package/dist/templates/call-audit/workflows/audit.ts +305 -0
  9. package/dist/templates/call-audit/workflows/ingest.ts +259 -0
  10. package/dist/templates/call-audit/workflows/media.ts +647 -0
  11. package/dist/templates/call-audit/workflows/summarize.ts +206 -0
  12. package/dist/templates/call-audit/workflows/sync-api.ts +44 -0
  13. package/dist/templates/call-audit/workflows/temp-media.ts +138 -0
  14. package/dist/templates/recap-workflow/agent.test.ts +11 -3
  15. package/dist/templates/recap-workflow/workflows/recap.ts +19 -8
  16. package/dist/templates/spoken-summary/agent.test.ts +343 -0
  17. package/dist/templates/spoken-summary/agent.ts +142 -0
  18. package/dist/templates/spoken-summary/client.tsx +225 -0
  19. package/dist/templates/spoken-summary/workflows/summarize.ts +242 -0
  20. package/dist/templates/spoken-summary/workflows/transcribe.ts +145 -0
  21. package/dist/templates/transcription-workflow/agent.test.ts +241 -18
  22. package/dist/templates/transcription-workflow/agent.ts +20 -6
  23. package/dist/templates/transcription-workflow/workflows/batch.ts +75 -173
  24. package/dist/templates/transcription-workflow/workflows/normalize.ts +343 -0
  25. package/dist/templates/transcription-workflow/workflows/stream.ts +6 -4
  26. package/dist/templates/transcription-workflow/workflows/sync-api.ts +26 -94
  27. package/dist/templates/transcription-workflow/workflows/transcribe.ts +23 -14
  28. package/dist/templates/transcription-workflow/workflows/wav.ts +31 -0
  29. package/package.json +3 -3
@@ -17,14 +17,18 @@
17
17
  * the decoder happily transcribes into confident nonsense.
18
18
  */
19
19
 
20
+ import { readdir } from "node:fs/promises";
21
+ import { tmpdir } from "node:os";
22
+ import { FfmpegError } from "@alexkroman1/aai/ffmpeg";
20
23
  import { stubReporter, stubStepFetch, stubUploads } from "@alexkroman1/aai/testing";
21
24
  import { omitUndefined, readUpload } from "@alexkroman1/aai/utils";
22
25
  import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
23
26
  import { FatalError, RetryableError } from "workflow";
24
27
  import { z } from "zod";
25
28
  import agentDef, { transcribe, transcribeBatch, transcribeStream } from "./agent.ts";
26
- import { createJob, pollTranscript, readTranscript, uploadToProvider } from "./workflows/batch.ts";
27
- import { planStreamed, probeUpload } from "./workflows/stream.ts";
29
+ import { createJob, pollTranscript, uploadToProvider } from "./workflows/batch.ts";
30
+ import { classifyFfmpeg, cuttable, normalizeRecording } from "./workflows/normalize.ts";
31
+ import { expectedSegments, planStreamed, probeUpload } from "./workflows/stream.ts";
28
32
  import {
29
33
  clock,
30
34
  mergeTranscript,
@@ -819,45 +823,59 @@ describe("the async flow", () => {
819
823
  stubBatch(() => ({ body: { status: "error", error: "audio too quiet" } }));
820
824
  // The provider has decided; no number of polls changes it, so this must not come
821
825
  // back as "not done yet".
822
- await expect(pollTranscript("tr_1")).rejects.toBeInstanceOf(FatalError);
826
+ await expect(pollTranscript(UPLOAD_ID, "tr_1", STARTED_AT)).rejects.toBeInstanceOf(FatalError);
823
827
  });
824
828
 
825
829
  test("pollTranscript answers `done` on completed and not before", async () => {
826
830
  publishRecording(new Uint8Array(10));
827
831
  stubBatch(() => ({ body: { status: "processing" } }));
828
- await expect(pollTranscript("tr_1")).resolves.toEqual({ done: false, status: "processing" });
832
+ await expect(pollTranscript(UPLOAD_ID, "tr_1", STARTED_AT)).resolves.toEqual({ done: false });
829
833
  });
830
834
 
831
835
  test("an unknown status is NOT done, so a new one cannot end a run early", async () => {
832
836
  publishRecording(new Uint8Array(10));
833
837
  stubBatch(() => ({ body: {} }));
834
- await expect(pollTranscript("tr_1")).resolves.toMatchObject({ done: false });
838
+ await expect(pollTranscript(UPLOAD_ID, "tr_1", STARTED_AT)).resolves.toMatchObject({
839
+ done: false,
840
+ });
835
841
  });
836
842
 
837
- test("readTranscript reports the provider's own duration and ONE segment", async () => {
843
+ test("a completed poll carries the transcript ONE request, not two", async () => {
838
844
  publishRecording(new Uint8Array(10), "standup.wav");
839
- stubBatch(() => ({ body: { text: " hello there ", audio_duration: 12.5 } }));
840
- await expect(readTranscript(UPLOAD_ID, "tr_1", STARTED_AT)).resolves.toMatchObject({
841
- source: "standup.wav",
842
- // Not a fudge: the async API transcribed the recording in one piece, which is
843
- // the difference this flow is here to show.
844
- segments: 1,
845
- durationMs: 12_500,
846
- words: 2,
847
- transcript: "hello there",
845
+ // It used to poll for a status and then fetch the identical URL again for the
846
+ // text the poll already had in its hand.
847
+ const calls = stubBatch(() => ({
848
+ body: { status: "completed", text: " hello there ", audio_duration: 12.5 },
849
+ }));
850
+ await expect(pollTranscript(UPLOAD_ID, "tr_1", STARTED_AT)).resolves.toMatchObject({
851
+ done: true,
852
+ transcript: {
853
+ source: "standup.wav",
854
+ // Not a fudge: the async API transcribed the recording in one piece, which is
855
+ // the difference this flow is here to show.
856
+ segments: 1,
857
+ durationMs: 12_500,
858
+ words: 2,
859
+ transcript: "hello there",
860
+ },
848
861
  });
862
+ expect(calls).toHaveLength(1);
849
863
  });
850
864
 
851
865
  test("a rate limit is RETRYABLE, so a busy minute does not fail the run", async () => {
852
866
  publishRecording(new Uint8Array(10));
853
867
  stubBatch(() => ({ status: 429, body: { error: "slow down" } }));
854
- await expect(pollTranscript("tr_1")).rejects.toBeInstanceOf(RetryableError);
868
+ await expect(pollTranscript(UPLOAD_ID, "tr_1", STARTED_AT)).rejects.toBeInstanceOf(
869
+ RetryableError,
870
+ );
855
871
  });
856
872
 
857
873
  test("all three flows report the same SHAPE, which is what lets one page render any", async () => {
858
874
  publishRecording(new Uint8Array(10), "standup.wav");
859
- stubBatch(() => ({ body: { text: "hi", audio_duration: 1 } }));
860
- const batched = await readTranscript(UPLOAD_ID, "tr_1", STARTED_AT);
875
+ stubBatch(() => ({ body: { status: "completed", text: "hi", audio_duration: 1 } }));
876
+ const progress = await pollTranscript(UPLOAD_ID, "tr_1", STARTED_AT);
877
+ if (!progress.done) return expect.fail("the stub reports a completed job");
878
+ const batched = progress.transcript;
861
879
  // One key set, so the page's summary line renders every flow's output. A field on
862
880
  // one flow and not the others is a panel that shows it for some runs and not
863
881
  // others, with nothing saying why.
@@ -873,3 +891,208 @@ describe("the async flow", () => {
873
891
  expect(batched.elapsedMs).toBeGreaterThan(0);
874
892
  });
875
893
  });
894
+
895
+ describe("normalizing the recording", () => {
896
+ /**
897
+ * The CONVERSION is not driven here, and the reason is the tier rather than the
898
+ * code: it spawns ffmpeg and writes a temp file, neither of which a unit test
899
+ * may do. What is reachable is everything that DECIDES — whether a file needs
900
+ * converting at all, and how a conversion's failure is classified — and those
901
+ * are the two places a mistake is silent. A file wrongly passed through fails
902
+ * later in `splitRecording` with a message about a header; a `timeout`
903
+ * classified as fatal is a run that gives up on work that would have finished.
904
+ */
905
+ test("a canonical WAV is cuttable, so the desk converts nothing", () => {
906
+ expect(cuttable(wavFile(MONO_16K, 32_000), 44 + 32_000)).toBe(true);
907
+ });
908
+
909
+ test("an extra chunk before the samples is still cuttable", () => {
910
+ // The `LIST`-chunk case, which is the reason the probe window is 64 KB rather
911
+ // than 44 bytes: a file the walk CAN read must not be re-encoded.
912
+ const head = wavFile(MONO_16K, 32_000, { extraChunk: "recorder" });
913
+ expect(cuttable(head, head.length + 32_000)).toBe(true);
914
+ });
915
+
916
+ test("an m4a is not, which is what puts ffmpeg in the path", () => {
917
+ // An MPEG-4 `ftyp` box — what a phone recording really starts with.
918
+ const m4a = new Uint8Array([
919
+ 0, 0, 0, 0x20, 0x66, 0x74, 0x79, 0x70, 0x4d, 0x34, 0x41, 0x20, 0, 0, 0, 0,
920
+ ]);
921
+ expect(cuttable(m4a, 4_000_000)).toBe(false);
922
+ });
923
+
924
+ test("a WAV whose encoding is not linear PCM is not", () => {
925
+ // The case an `ffprobe`-based check gets WRONG, which is why the check is
926
+ // `parseWav` itself: this file reports a PCM codec to ffprobe and is refused
927
+ // by the parser, so a probe would pass it through and the cut would fail.
928
+ const extensible = wavFile(MONO_16K, 32_000);
929
+ new DataView(extensible.buffer).setUint16(20, 0xff_fe, true);
930
+ expect(cuttable(extensible, 44 + 32_000)).toBe(false);
931
+ });
932
+
933
+ test("a WAV too dense to cut is not, and downsampling is what repairs it", () => {
934
+ // Past `MAX_BYTES_PER_SECOND`, so `parseWav` refuses it — and a conversion to
935
+ // 16 kHz mono makes it cuttable, which is a fix the desk gets for free from
936
+ // asking the parser rather than asking about the codec.
937
+ const dense = wavFile({ sampleRate: 4_000_000, channels: 8, bitsPerSample: 32 }, 32_000);
938
+ expect(cuttable(dense, 44 + 32_000)).toBe(false);
939
+ });
940
+
941
+ test("an already-cuttable recording keeps the id it came in under", async () => {
942
+ // The property that matters: no second upload, so the fan-out reads the file
943
+ // the caller stored. A step that copied it would double the storage every run
944
+ // pays for and would still report success.
945
+ publishRecording(wavFile(MONO_16K, 32_000), "standup.wav");
946
+ const reporter = stubReporter();
947
+ try {
948
+ await expect(normalizeRecording(UPLOAD_ID)).resolves.toEqual({
949
+ recording: UPLOAD_ID,
950
+ converted: false,
951
+ });
952
+ expect(reporter.lines.join(" ")).toContain("already linear-PCM WAV");
953
+ } finally {
954
+ reporter.restore();
955
+ }
956
+ });
957
+
958
+ test("a conversion that ffmpeg REFUSED is fatal, so it is not attempted five times", () => {
959
+ // `exit` means ffmpeg read the file and would read it the same way again.
960
+ expect(() =>
961
+ classifyFfmpeg(
962
+ new FfmpegError({
963
+ kind: "exit",
964
+ message: "Invalid data found when processing input",
965
+ binary: "ffmpeg",
966
+ argv: [],
967
+ exitCode: 1,
968
+ }),
969
+ ),
970
+ ).toThrow(FatalError);
971
+ });
972
+
973
+ test("a conversion that ran out of time keeps its retries, and its argv", () => {
974
+ // Rethrown UNCHANGED rather than wrapped, which is what `toStepError` does
975
+ // with an error carrying no verdict — and the DevKit's default for anything
976
+ // that is not a `FatalError` is to retry. Asserting the class survives is
977
+ // asserting the diagnosis does: `argv` is the command you paste into a shell,
978
+ // and a `new RetryableError(message)` here would throw it away.
979
+ const timedOut = new FfmpegError({
980
+ kind: "timeout",
981
+ message: "timed out",
982
+ binary: "ffmpeg",
983
+ argv: ["-i", "source"],
984
+ });
985
+ expect(() => classifyFfmpeg(timedOut)).toThrow(timedOut);
986
+ expect(() => classifyFfmpeg(timedOut)).not.toThrow(FatalError);
987
+ });
988
+
989
+ test("no ffmpeg at all is fatal — a retry cannot install one", () => {
990
+ // The `aai dev` case. Fatal deliberately: the message already carries the
991
+ // install instructions, and four more attempts only delay a person reading it.
992
+ expect(() =>
993
+ classifyFfmpeg(
994
+ new FfmpegError({
995
+ kind: "missing-binary",
996
+ message: "ffmpeg is not installed",
997
+ binary: "ffmpeg",
998
+ argv: [],
999
+ }),
1000
+ ),
1001
+ ).toThrow(FatalError);
1002
+ });
1003
+
1004
+ test("something that is not an ffmpeg failure at all is fatal", () => {
1005
+ // The store rejecting a write, say. Fatal rather than retryable because this
1006
+ // step's own `maxRetries` exists for the I/O halves that report themselves as
1007
+ // transient; an unrecognized error has said nothing about being worth another
1008
+ // attempt, and guessing yes is how a run burns its budget before failing.
1009
+ expect(() => classifyFfmpeg(new Error("no space left on device"))).toThrow(FatalError);
1010
+ });
1011
+ });
1012
+
1013
+ describe("expectedSegments", () => {
1014
+ /** A plan over 16 kHz mono, cut into three 90-second segments. */
1015
+ const PLAN = {
1016
+ format: { ...MONO_16K, dataStart: 44, dataEnd: 44 + 270 * 32_000 },
1017
+ segments: [0, 1, 2].map((index) => ({
1018
+ index,
1019
+ start: 44 + index * 90 * 32_000,
1020
+ end: 44 + (index + 1) * 90 * 32_000,
1021
+ startMs: index * 90_000,
1022
+ endMs: (index + 1) * 90_000,
1023
+ })),
1024
+ };
1025
+
1026
+ test("counts every segment once the whole recording has arrived", () => {
1027
+ expect(expectedSegments(PLAN, PLAN.format.dataEnd)).toBe(3);
1028
+ });
1029
+
1030
+ test("ignores segments that start past the end of a SHORT upload", () => {
1031
+ // The failure this guards is a run that never ENDS rather than one that fails:
1032
+ // the plan came from the header's declared length, so a recording that came up
1033
+ // short has segments beginning past the last byte, and waiting for them is
1034
+ // waiting for audio nobody is going to send.
1035
+ expect(expectedSegments(PLAN, 44 + 100 * 32_000)).toBe(2);
1036
+ expect(expectedSegments(PLAN, 44 + 1)).toBe(1);
1037
+ });
1038
+
1039
+ test("an upload with nothing in it expects no segments at all", () => {
1040
+ expect(expectedSegments(PLAN, 0)).toBe(0);
1041
+ });
1042
+ });
1043
+
1044
+ describe("the conversion, up to the spawn", () => {
1045
+ /**
1046
+ * Reaches the point where ffmpeg would run and stops there, DETERMINISTICALLY.
1047
+ *
1048
+ * `AAI_FFPROBE_PATH` names the binary the SDK resolves, so pointing it at a path
1049
+ * that does not exist produces `kind: "missing-binary"` on every machine — one
1050
+ * where ffmpeg is installed, one where it is not, and CI's Linux leg alike. A
1051
+ * test that instead relied on ffmpeg being ABSENT would pass on a laptop and
1052
+ * behave differently in CI.
1053
+ *
1054
+ * What it covers is the whole step up to the subprocess: reading the header,
1055
+ * deciding the file needs converting, and materializing it to a temp file. Plus
1056
+ * the behaviour a developer actually meets — `aai dev` with no ffmpeg is the one
1057
+ * place dev/prod parity is partial, and it must fail FATALLY with an installable
1058
+ * remedy rather than burn five attempts on a binary that will not appear.
1059
+ */
1060
+ test("materializes the recording, then fails fatally with no ffprobe", async () => {
1061
+ vi.stubEnv("AAI_FFPROBE_PATH", "/nonexistent/aai-test/ffprobe");
1062
+ vi.stubEnv("AAI_FFMPEG_PATH", "/nonexistent/aai-test/ffmpeg");
1063
+ // An m4a `ftyp` box, so `cuttable` says no and the conversion path is taken.
1064
+ publishRecording(
1065
+ new Uint8Array([0, 0, 0, 0x20, 0x66, 0x74, 0x79, 0x70, 0x4d, 0x34, 0x41, 0x20]),
1066
+ "standup.m4a",
1067
+ );
1068
+ const reporter = stubReporter();
1069
+ try {
1070
+ // Fatal, not retryable: four more attempts find the same missing binary, and
1071
+ // the message already carries the install instructions.
1072
+ await expect(normalizeRecording(UPLOAD_ID)).rejects.toThrow(/ffprobe/);
1073
+ await expect(normalizeRecording(UPLOAD_ID)).rejects.toBeInstanceOf(FatalError);
1074
+ // It got as far as deciding the file needs converting — the failure is the
1075
+ // binary, not the input.
1076
+ expect(reporter.lines.join(" ")).toContain("standup.m4a");
1077
+ expect(normalizeRecording.maxRetries).toBe(5);
1078
+ } finally {
1079
+ reporter.restore();
1080
+ }
1081
+ });
1082
+
1083
+ test("leaves no temp directory behind when the conversion fails", async () => {
1084
+ // The `finally`, on the path that matters: a guest's disk is small, and a step
1085
+ // that leaked a directory per failed run would fill it.
1086
+ vi.stubEnv("AAI_FFPROBE_PATH", "/nonexistent/aai-test/ffprobe");
1087
+ const leaked = (names: string[]) => names.filter((n) => n.startsWith("aai-normalize-"));
1088
+ const before = leaked(await readdir(tmpdir()));
1089
+ publishRecording(new Uint8Array([0, 0, 0, 0x20, 0x66, 0x74, 0x79, 0x70]), "standup.m4a");
1090
+ const reporter = stubReporter();
1091
+ try {
1092
+ await expect(normalizeRecording(UPLOAD_ID)).rejects.toBeInstanceOf(FatalError);
1093
+ expect(leaked(await readdir(tmpdir()))).toEqual(before);
1094
+ } finally {
1095
+ reporter.restore();
1096
+ }
1097
+ });
1098
+ });
@@ -21,10 +21,14 @@
21
21
  * `DATABASE_URL` under `aai dev`). REQUIRED here, unlike most workflow apps:
22
22
  * a run survives without it, but an UPLOAD's record is a row, so the form
23
23
  * below refuses by name until storage is on.
24
- * - **A linear-PCM WAV.** The cutting is arithmetic over byte offsets, which is
25
- * only possible on uncompressed audio; `workflows/wav.ts` says so in more
26
- * detail, and an unsupported file fails the run by name with the `ffmpeg`
27
- * line that fixes it.
24
+ * - **ffmpeg, under `aai dev` only.** A deployed guest's image installs it; on a
25
+ * laptop it is whatever is on `PATH` (or `AAI_FFMPEG_PATH`). The `transcribe`
26
+ * flow needs it for anything that is not already a linear-PCM WAV, because the
27
+ * cutting is arithmetic over byte offsets and that is only possible on
28
+ * uncompressed audio — `workflows/normalize.ts` is the conversion and
29
+ * `workflows/wav.ts` is why it has to happen. A WAV needs no ffmpeg at all,
30
+ * and `transcribeStream` never uses it: a recording that is still uploading is
31
+ * not something a decoder can be pointed at.
28
32
  *
29
33
  * ## The recording is UPLOADED, and the run carries its id
30
34
  *
@@ -49,7 +53,8 @@
49
53
  * | client sends | `POST /uploads` | `PUT /uploads/<id>` | `POST /uploads` |
50
54
  * | shape | plan, fan out, merge | poll, fan out, merge | submit, poll, read |
51
55
  * | client hook | `useWorkflowSubmit` | `useWorkflowStream` | `useWorkflowSubmit` |
52
- * | accepts | linear-PCM WAV | linear-PCM WAV | **any audio** |
56
+ * | accepts | **any audio** | linear-PCM WAV | **any audio** |
57
+ * | converts first | when it must | never | not needed |
53
58
  * | segments | 7 for 10 minutes | 7 | **1** |
54
59
  *
55
60
  * The first two are the same fan-out arranged two ways, and the difference between
@@ -102,7 +107,12 @@ export const transcribe = workflow({
102
107
  input: z.object({
103
108
  // A plain string, because an upload id is what the run really receives. What
104
109
  // makes it a file picker rather than a text box is the `uploads` line below.
105
- recording: z.string().describe("A linear-PCM WAV recording (16-bit or 8-bit, any rate)"),
110
+ //
111
+ // It says "any recording" now, and that is the whole of what
112
+ // `workflows/normalize.ts` bought: this used to name linear-PCM WAV and mean
113
+ // it, so the desk's real front door was a sentence telling the caller to run
114
+ // ffmpeg themselves. The cut still needs WAV — the run makes one.
115
+ recording: z.string().describe("Any recording — WAV, MP3, M4A, or a video's audio track"),
106
116
  }),
107
117
  // The one line that makes the form take a file: `<WorkflowFields>` renders a
108
118
  // picker for this property, `useWorkflowSubmit` stores the chosen file, and
@@ -128,6 +138,10 @@ export const transcribe = workflow({
128
138
  export const transcribeStream = workflow({
129
139
  description: "Transcribe a recording while it is still uploading",
130
140
  input: z.object({
141
+ // Still WAV, and this is the one flow where that is not a limitation to be
142
+ // fixed: it cuts the recording while the bytes are arriving, and a partial
143
+ // file is not something ffmpeg can transcode. `transcribe` converts because
144
+ // it has the whole file before it plans anything.
131
145
  recording: z.string().describe("A linear-PCM WAV recording (16-bit or 8-bit, any rate)"),
132
146
  }),
133
147
  uploads: ["recording"],