@alexkroman1/aai-cli 11.0.0 → 13.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/scaffold/package.json +11 -11
- package/dist/templates/call-audit/agent.test.ts +23 -0
- package/dist/templates/call-audit/workflows/media.ts +60 -34
- package/dist/templates/podcast-digest/agent.test.ts +66 -0
- package/dist/templates/podcast-digest/workflows/feeds.ts +52 -14
- package/dist/templates/recap-workflow/agent.test.ts +27 -0
- package/dist/templates/recap-workflow/workflows/recap.ts +74 -29
- package/dist/templates/transcription-workflow/agent.test.ts +22 -12
- package/dist/templates/transcription-workflow/workflows/stream.ts +74 -23
- package/package.json +9 -9
|
@@ -14,24 +14,24 @@
|
|
|
14
14
|
"publish:agent": "aai publish"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@alexkroman1/aai": "^
|
|
18
|
-
"@alexkroman1/aai-runtime": "^
|
|
19
|
-
"@alexkroman1/aai-ui": "^
|
|
17
|
+
"@alexkroman1/aai": "^13.0.0",
|
|
18
|
+
"@alexkroman1/aai-runtime": "^13.0.0",
|
|
19
|
+
"@alexkroman1/aai-ui": "^13.0.0",
|
|
20
20
|
"react": "^19.2.8",
|
|
21
21
|
"react-dom": "^19.2.8",
|
|
22
22
|
"tailwindcss": "^4.0.0",
|
|
23
|
-
"xstate": "^5.32.
|
|
24
|
-
"zod": "^4.4
|
|
23
|
+
"xstate": "^5.32.6",
|
|
24
|
+
"zod": "^4.5.4"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
|
-
"@alexkroman1/aai-cli": "^
|
|
27
|
+
"@alexkroman1/aai-cli": "^13.0.0",
|
|
28
28
|
"@tailwindcss/vite": "^4.3.3",
|
|
29
|
-
"@types/node": "^26.
|
|
29
|
+
"@types/node": "^26.4.1",
|
|
30
30
|
"@types/react": "^19.2.18",
|
|
31
|
-
"@types/react-dom": "^19.2.
|
|
32
|
-
"@vitejs/plugin-react": "^6.
|
|
31
|
+
"@types/react-dom": "^19.2.5",
|
|
32
|
+
"@vitejs/plugin-react": "^6.1.1",
|
|
33
33
|
"typescript": "^7.0.2",
|
|
34
|
-
"vite": "^8.2.
|
|
35
|
-
"vitest": "^4.1.
|
|
34
|
+
"vite": "^8.2.2",
|
|
35
|
+
"vitest": "^4.1.11"
|
|
36
36
|
}
|
|
37
37
|
}
|
|
@@ -321,8 +321,31 @@ describe("reading the loudness measurement", () => {
|
|
|
321
321
|
expect(() => parseLoudness(missing)).toThrow(/input_tp/);
|
|
322
322
|
});
|
|
323
323
|
|
|
324
|
+
test("names the KEY when the value is unreadable, and QUOTES what came", () => {
|
|
325
|
+
// The other half of the case above: the key is there and holds something no
|
|
326
|
+
// number can be read out of — `"n/a"`, which is ffmpeg's own spelling for a
|
|
327
|
+
// value it could not measure, or a type that was never a number. Terminal
|
|
328
|
+
// either way, and the message has to carry the value, which is why the
|
|
329
|
+
// object gate and the value schema are two parses rather than one.
|
|
330
|
+
expect(() => parseLoudness(LOUDNORM_STDERR.replace('"-7.42"', '"n/a"'))).toThrow(
|
|
331
|
+
/input_tp.*got "n\/a"/,
|
|
332
|
+
);
|
|
333
|
+
expect(() => parseLoudness(LOUDNORM_STDERR.replace('"-7.42"', "[1,2]"))).toThrow(/input_tp/);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test("coerces exactly as `Number(…)` does, corners included", () => {
|
|
337
|
+
// Every value is a STRING on the wire, so the read has always been a
|
|
338
|
+
// coercion — and `Number(null)` is 0, not `NaN`. Pinned rather than
|
|
339
|
+
// tightened: the schema replaced a hand-written reader, so its corners are
|
|
340
|
+
// the ones that reader had, and narrowing them is a change to make on
|
|
341
|
+
// purpose rather than a side effect of declaring a schema.
|
|
342
|
+
const nulled = LOUDNORM_STDERR.replace('"0.13"', "null");
|
|
343
|
+
expect(parseLoudness(nulled).targetOffset).toBe(0);
|
|
344
|
+
});
|
|
345
|
+
|
|
324
346
|
test("refuses a block that is not JSON at all", () => {
|
|
325
347
|
expect(() => parseLoudness("[loudnorm] {not json}")).toThrow(MediaAnalysisError);
|
|
348
|
+
expect(() => parseLoudness("[loudnorm] {not json}")).toThrow(/not JSON/);
|
|
326
349
|
});
|
|
327
350
|
});
|
|
328
351
|
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
*/
|
|
60
60
|
|
|
61
61
|
import type { PcmFormat } from "@alexkroman1/aai/step";
|
|
62
|
-
import {
|
|
62
|
+
import { z } from "zod";
|
|
63
63
|
|
|
64
64
|
/**
|
|
65
65
|
* The format every recording is converted to before anything measures it.
|
|
@@ -255,9 +255,9 @@ export function measureLoudnessArgs(input: string): string[] {
|
|
|
255
255
|
* `{…}` in the text — last because a re-run's block would follow an earlier one,
|
|
256
256
|
* and because nothing else ffmpeg logs at info level is brace-delimited.
|
|
257
257
|
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
258
|
+
* Both halves of the read are declared below — {@link LoudnessBlock} for "is it
|
|
259
|
+
* an object", {@link LoudnessValues} for the five values, every one of which
|
|
260
|
+
* arrives as a STRING and has to coerce without silently yielding `NaN`.
|
|
261
261
|
*/
|
|
262
262
|
export function parseLoudness(stderr: string): Loudness {
|
|
263
263
|
const open = stderr.lastIndexOf("{");
|
|
@@ -268,23 +268,68 @@ export function parseLoudness(stderr: string): Loudness {
|
|
|
268
268
|
"loses `-loglevel info`, since `print_format=json` writes through ffmpeg's log.",
|
|
269
269
|
);
|
|
270
270
|
}
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
|
|
276
|
-
|
|
271
|
+
// Two parses, one per sentence this function can say. The block being an
|
|
272
|
+
// OBJECT and the five values being readable are different failures with
|
|
273
|
+
// different remedies, and the second message quotes the value ffmpeg actually
|
|
274
|
+
// printed — which needs the block still in hand, so the gate cannot be folded
|
|
275
|
+
// into the schema below.
|
|
276
|
+
const block = LoudnessBlock.safeParse(safeJson(stderr.slice(open, close + 1)));
|
|
277
|
+
if (!block.success) {
|
|
277
278
|
throw new MediaAnalysisError("The loudness pass printed a block that is not JSON.");
|
|
278
279
|
}
|
|
280
|
+
|
|
281
|
+
const values = LoudnessValues.safeParse(block.data);
|
|
282
|
+
if (!values.success) {
|
|
283
|
+
// Zod reports issues in the schema's own field order, so the first one names
|
|
284
|
+
// the same key the first per-key read named — and there is always a key,
|
|
285
|
+
// the object gate above having already passed.
|
|
286
|
+
const key = String(values.error.issues[0]?.path[0]);
|
|
287
|
+
throw new MediaAnalysisError(
|
|
288
|
+
`The loudness pass reported no usable \`${key}\` (got ${JSON.stringify(block.data[key])}).`,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
279
292
|
return {
|
|
280
|
-
inputLufs:
|
|
281
|
-
inputTruePeak:
|
|
282
|
-
inputRange:
|
|
283
|
-
inputThreshold:
|
|
284
|
-
targetOffset:
|
|
293
|
+
inputLufs: values.data.input_i,
|
|
294
|
+
inputTruePeak: values.data.input_tp,
|
|
295
|
+
inputRange: values.data.input_lra,
|
|
296
|
+
inputThreshold: values.data.input_thresh,
|
|
297
|
+
targetOffset: values.data.target_offset,
|
|
285
298
|
};
|
|
286
299
|
}
|
|
287
300
|
|
|
301
|
+
/**
|
|
302
|
+
* Is the found `{…}` an object at all?
|
|
303
|
+
*
|
|
304
|
+
* The reachable failure is {@link safeJson} answering `undefined` — a brace pair
|
|
305
|
+
* found in ffmpeg's chatter with something other than JSON between them — and
|
|
306
|
+
* that is a different sentence from a value being unreadable, which is why this
|
|
307
|
+
* gate exists at all rather than being folded into {@link LoudnessValues}.
|
|
308
|
+
*
|
|
309
|
+
* `looseObject`, not `object`: it asks the ONE question and says nothing about
|
|
310
|
+
* keys, which matters because ffmpeg prints ten and the schema below reads five.
|
|
311
|
+
*/
|
|
312
|
+
const LoudnessBlock = z.looseObject({});
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The five numbers, in the order {@link parseLoudness} reports them missing.
|
|
316
|
+
*
|
|
317
|
+
* `z.coerce.number()` is the load-bearing choice: every value arrives as a
|
|
318
|
+
* STRING (`"input_i" : "-16.19"`), which is ffmpeg's shape and not a quirk of
|
|
319
|
+
* one version, so the schema has to coerce exactly as `Number(…)` did. What it
|
|
320
|
+
* adds is the check — zod 4's `z.number()` refuses `NaN` and both infinities,
|
|
321
|
+
* so a key ffmpeg stopped printing fails HERE with its name attached instead of
|
|
322
|
+
* flowing into pass two's argv as the literal text `NaN` and coming back as an
|
|
323
|
+
* ffmpeg option-parsing error about a filter.
|
|
324
|
+
*/
|
|
325
|
+
const LoudnessValues = z.object({
|
|
326
|
+
input_i: z.coerce.number(),
|
|
327
|
+
input_tp: z.coerce.number(),
|
|
328
|
+
input_lra: z.coerce.number(),
|
|
329
|
+
input_thresh: z.coerce.number(),
|
|
330
|
+
target_offset: z.coerce.number(),
|
|
331
|
+
});
|
|
332
|
+
|
|
288
333
|
/**
|
|
289
334
|
* Pass two: apply the measurement, find the pauses, and write the audio.
|
|
290
335
|
*
|
|
@@ -616,22 +661,3 @@ function safeJson(text: string): unknown {
|
|
|
616
661
|
return undefined;
|
|
617
662
|
}
|
|
618
663
|
}
|
|
619
|
-
|
|
620
|
-
/**
|
|
621
|
-
* One of `loudnorm`'s values, as a number.
|
|
622
|
-
*
|
|
623
|
-
* Checked rather than coerced: every value arrives as a string, so `Number(…)` on
|
|
624
|
-
* a key ffmpeg stopped printing yields `NaN`, which then flows into the second
|
|
625
|
-
* pass's argv as the literal text `NaN` and makes ffmpeg reject the filter with a
|
|
626
|
-
* message about option parsing. Naming the key here is what turns that into a
|
|
627
|
-
* sentence about the analysis.
|
|
628
|
-
*/
|
|
629
|
-
function numberAt(raw: Record<string, unknown>, key: string): number {
|
|
630
|
-
const parsed = Number(raw[key]);
|
|
631
|
-
if (!Number.isFinite(parsed)) {
|
|
632
|
-
throw new MediaAnalysisError(
|
|
633
|
-
`The loudness pass reported no usable \`${key}\` (got ${JSON.stringify(raw[key])}).`,
|
|
634
|
-
);
|
|
635
|
-
}
|
|
636
|
-
return parsed;
|
|
637
|
-
}
|
|
@@ -512,6 +512,72 @@ describe("discoverEpisodes", () => {
|
|
|
512
512
|
expect(episodes[0]?.podcastTitle).toBe("Example Show");
|
|
513
513
|
});
|
|
514
514
|
|
|
515
|
+
/**
|
|
516
|
+
* The three degradation cases of the iTunes payload, which is third-party
|
|
517
|
+
* JSON reached without an API key — so every one of them is a shape a live
|
|
518
|
+
* run really meets, and NONE of them may fail the run. A schema parsed with a
|
|
519
|
+
* throwing `parse` fails all three.
|
|
520
|
+
*/
|
|
521
|
+
test("keeps the usable hit when a neighbouring entry is not even an object", async () => {
|
|
522
|
+
stub({
|
|
523
|
+
"itunes.apple.com/lookup": {
|
|
524
|
+
body: {
|
|
525
|
+
results: [
|
|
526
|
+
"no results",
|
|
527
|
+
7,
|
|
528
|
+
null,
|
|
529
|
+
{ feedUrl: "https://show.test/feed.xml", collectionName: "Example Show" },
|
|
530
|
+
],
|
|
531
|
+
},
|
|
532
|
+
},
|
|
533
|
+
"show.test/feed.xml": { body: THREE_EPISODES },
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
const episodes = await discoverEpisodes("https://podcasts.apple.com/us/podcast/x/id123", 1);
|
|
537
|
+
|
|
538
|
+
expect(episodes[0]?.podcastTitle).toBe("Example Show");
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
test("reads a malformed lookup payload as no hits, and takes the other route", async () => {
|
|
542
|
+
// An interstitial, a rate-limit body, a schema change: `results` is not an
|
|
543
|
+
// array, so there is nothing to search — which is what the page fallback
|
|
544
|
+
// below this exists for, and is a normal outcome rather than a failed run.
|
|
545
|
+
stub({
|
|
546
|
+
"itunes.apple.com/lookup": { body: { results: "temporarily unavailable" } },
|
|
547
|
+
"podcasts.apple.com": {
|
|
548
|
+
body:
|
|
549
|
+
'<script id="serialized-server-data">' +
|
|
550
|
+
'{"d":[{"model":{"adamId":"123","feedUrl":"https://show.test/feed.xml"}}]}' +
|
|
551
|
+
"</script>",
|
|
552
|
+
},
|
|
553
|
+
"show.test/feed.xml": { body: THREE_EPISODES },
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
expect(await discoverEpisodes("https://podcasts.apple.com/us/podcast/x/id123", 1)).toHaveLength(
|
|
557
|
+
1,
|
|
558
|
+
);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
test("falls back to the feed's host when the hit names the show unusably", async () => {
|
|
562
|
+
// `collectionName` of the wrong type and no `trackName` behind it is the
|
|
563
|
+
// same case as neither field being there: the hit still carries the one
|
|
564
|
+
// field the caller came for, so it is used, and the title falls back to the
|
|
565
|
+
// feed's host. The feed here has no `<title>` of its own, which is what
|
|
566
|
+
// makes that fallback observable at all.
|
|
567
|
+
stub({
|
|
568
|
+
"itunes.apple.com/lookup": {
|
|
569
|
+
body: { results: [{ feedUrl: "https://show.test/feed.xml", collectionName: 42 }] },
|
|
570
|
+
},
|
|
571
|
+
"show.test/feed.xml": {
|
|
572
|
+
body: `<?xml version="1.0"?><rss><channel>${itemXml("only", "Tue, 01 Jan 2030 00:00:00 GMT")}</channel></rss>`,
|
|
573
|
+
},
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
const episodes = await discoverEpisodes("https://podcasts.apple.com/us/podcast/x/id123", 1);
|
|
577
|
+
|
|
578
|
+
expect(episodes[0]?.podcastTitle).toBe("show.test");
|
|
579
|
+
});
|
|
580
|
+
|
|
515
581
|
test("falls back to Apple's own page when the lookup omits the feed", async () => {
|
|
516
582
|
stub({
|
|
517
583
|
"itunes.apple.com/lookup": { body: { results: [{ collectionName: "Example Show" }] } },
|
|
@@ -68,6 +68,7 @@ import { type FeedItem, pageMetadata, parseFeed } from "@alexkroman1/aai/html";
|
|
|
68
68
|
import { report } from "@alexkroman1/aai/step";
|
|
69
69
|
import { FatalError, stepFetchOk } from "@alexkroman1/aai/step-errors";
|
|
70
70
|
import { isRecord, omitUndefined, safeJsonParse } from "@alexkroman1/aai/utils";
|
|
71
|
+
import { z } from "zod";
|
|
71
72
|
|
|
72
73
|
/** How long any one of these lookups may take before it is a failure. */
|
|
73
74
|
const REQUEST_TIMEOUT_MS = 30_000;
|
|
@@ -463,27 +464,64 @@ function normalizeTitle(value: string): string {
|
|
|
463
464
|
.trim();
|
|
464
465
|
}
|
|
465
466
|
|
|
467
|
+
/** One iTunes hit, reduced to the three fields this file reads. */
|
|
468
|
+
type AppleResult = { feedUrl?: string; title?: string; artist?: string };
|
|
469
|
+
|
|
466
470
|
/**
|
|
467
|
-
*
|
|
471
|
+
* One entry of an iTunes `results` array, reduced on the way through.
|
|
472
|
+
*
|
|
473
|
+
* Every field is `.catch(undefined)`, which is this schema's whole subject: the
|
|
474
|
+
* caller is looking for a hit with a `feedUrl` on it, so a neighbouring entry
|
|
475
|
+
* that spells `collectionName` as a number is a hit that does not match, never
|
|
476
|
+
* a reason to abandon the search. The reduction rides in the `transform` for
|
|
477
|
+
* the same reason the fields are declared here rather than at the call site —
|
|
478
|
+
* `collectionName ?? trackName` is a fact about iTunes' payload, and this is
|
|
479
|
+
* the module that is allowed to know vendor facts.
|
|
468
480
|
*
|
|
469
481
|
* `omitUndefined` rather than a conditional spread per field: under
|
|
470
482
|
* `exactOptionalPropertyTypes` an absent field and a field set to `undefined`
|
|
471
483
|
* are different types, and this is the SDK's one spelling for the difference.
|
|
484
|
+
* It is still needed after a schema, because a field the `.catch` above turned
|
|
485
|
+
* into `undefined` is PRESENT in zod's output holding `undefined` (only an
|
|
486
|
+
* absent field stays absent).
|
|
472
487
|
*/
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
488
|
+
const AppleResultEntry = z
|
|
489
|
+
.object({
|
|
490
|
+
feedUrl: z.string().optional().catch(undefined),
|
|
491
|
+
collectionName: z.string().optional().catch(undefined),
|
|
492
|
+
trackName: z.string().optional().catch(undefined),
|
|
493
|
+
artistName: z.string().optional().catch(undefined),
|
|
494
|
+
})
|
|
495
|
+
.transform(
|
|
496
|
+
(result): AppleResult =>
|
|
497
|
+
omitUndefined({
|
|
498
|
+
feedUrl: result.feedUrl,
|
|
499
|
+
title: result.collectionName ?? result.trackName,
|
|
500
|
+
artist: result.artistName,
|
|
501
|
+
}),
|
|
502
|
+
)
|
|
503
|
+
// An entry that is not an object at all — the one shape the fields above
|
|
504
|
+
// cannot absorb — becomes `null` and is dropped below, rather than taking the
|
|
505
|
+
// whole array down with it. That is the `.filter(isRecord)` this replaces.
|
|
506
|
+
.nullable()
|
|
507
|
+
.catch(null);
|
|
508
|
+
|
|
509
|
+
/** The iTunes lookup and search payloads, which share the one field read here. */
|
|
510
|
+
const AppleResponse = z.object({ results: z.array(AppleResultEntry) });
|
|
483
511
|
|
|
484
|
-
/**
|
|
485
|
-
|
|
486
|
-
|
|
512
|
+
/**
|
|
513
|
+
* The hits in an iTunes response, and NOTHING is an ordinary answer.
|
|
514
|
+
*
|
|
515
|
+
* `safeParse`, never `parse`: both callers treat an empty list as "no match
|
|
516
|
+
* found" and already have a sentence for it, so a body that is not an object,
|
|
517
|
+
* or one whose `results` is not an array, must degrade rather than fail the
|
|
518
|
+
* run. These are live third-party endpoints reached without an API key — a
|
|
519
|
+
* throw here would turn a rate-limit interstitial into a failed durable run.
|
|
520
|
+
*/
|
|
521
|
+
function appleResults(body: unknown): AppleResult[] {
|
|
522
|
+
const parsed = AppleResponse.safeParse(body);
|
|
523
|
+
if (!parsed.success) return [];
|
|
524
|
+
return parsed.data.results.filter((result) => result !== null);
|
|
487
525
|
}
|
|
488
526
|
|
|
489
527
|
// ---- HTTP -------------------------------------------------------------------
|
|
@@ -554,6 +554,33 @@ describe("checkTranscript", () => {
|
|
|
554
554
|
stubProvider({ status: "transcribing" });
|
|
555
555
|
await expect(checkTranscript("t_1")).rejects.toThrow(/unknown transcript status/);
|
|
556
556
|
});
|
|
557
|
+
|
|
558
|
+
test("drops a field of the wrong type rather than failing the poll", async () => {
|
|
559
|
+
// THE degradation rule, and the test that fails against a schema parsed
|
|
560
|
+
// with a throwing `parse`: the job is running and `status` says so, so a
|
|
561
|
+
// `text` the provider sent as a number is one unusable field — not a reason
|
|
562
|
+
// to end a run that has already paid for the transcription. Each optional
|
|
563
|
+
// field carries its own `.catch(undefined)` for exactly this.
|
|
564
|
+
stubProvider({ status: "processing", text: 42, error: [], audio_duration: "254" });
|
|
565
|
+
expect(await checkTranscript("t_1")).toEqual({ status: "processing" });
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
test("drops a non-finite duration, which `Math.round(x / 60)` cannot use", async () => {
|
|
569
|
+
// JSON cannot spell `Infinity`, so this arrives as a string or a null and
|
|
570
|
+
// has to be refused the same way — `z.number()` refuses both, which is the
|
|
571
|
+
// `Number.isFinite` test the hand-written reader carried.
|
|
572
|
+
stubProvider({ status: "completed", text: "Hello.", audio_duration: null });
|
|
573
|
+
expect(await checkTranscript("t_1")).toEqual({ status: "completed", text: "Hello." });
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
test("names an unreadable body as `undefined` rather than throwing twice", async () => {
|
|
577
|
+
// Valid JSON that is not this endpoint's object — a proxy that answered
|
|
578
|
+
// with a list, say. There is no status to report, so the sentence says
|
|
579
|
+
// exactly that rather than the report itself failing on the body it was
|
|
580
|
+
// sent to describe.
|
|
581
|
+
stubProvider([{ status: "completed" }]);
|
|
582
|
+
await expect(checkTranscript("t_1")).rejects.toThrow(/unknown transcript status: undefined/);
|
|
583
|
+
});
|
|
557
584
|
});
|
|
558
585
|
|
|
559
586
|
describe("discardTranscript — the compensation", () => {
|
|
@@ -120,7 +120,7 @@ import {
|
|
|
120
120
|
stepTranscribeSubmitClassified,
|
|
121
121
|
toStepError,
|
|
122
122
|
} from "@alexkroman1/aai/step-errors";
|
|
123
|
-
import { errorMessage,
|
|
123
|
+
import { errorMessage, omitUndefined } from "@alexkroman1/aai/utils";
|
|
124
124
|
import { z } from "zod";
|
|
125
125
|
import { retentionToken, transcriptToken } from "./tokens.ts";
|
|
126
126
|
|
|
@@ -278,6 +278,58 @@ export type TranscriptState = {
|
|
|
278
278
|
audioDuration?: number;
|
|
279
279
|
};
|
|
280
280
|
|
|
281
|
+
/**
|
|
282
|
+
* The four statuses this desk knows how to act on.
|
|
283
|
+
*
|
|
284
|
+
* Declared as a schema rather than checked with a `!==` chain because the union
|
|
285
|
+
* IS the provider's contract with this template — see {@link checkTranscript},
|
|
286
|
+
* which reads it as a value the saga branches on — and a schema is the one
|
|
287
|
+
* spelling that both narrows the type and names the four in the error.
|
|
288
|
+
*/
|
|
289
|
+
const TranscriptStatus = z.enum(["queued", "processing", "completed", "error"]);
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The poll response, as much of it as this desk reads.
|
|
293
|
+
*
|
|
294
|
+
* **The three optional fields DEGRADE and the status does not**, which is the
|
|
295
|
+
* whole reason each one is spelled differently. A provider that answers
|
|
296
|
+
* `"text": null` on a job still running, or drops `audio_duration` from a
|
|
297
|
+
* failed one, is describing a job this desk can still act on — so a field of
|
|
298
|
+
* the wrong type is `undefined` (`.catch`) and an absent one stays absent. A
|
|
299
|
+
* status outside the four is the opposite: nothing downstream knows what to do
|
|
300
|
+
* with it, and polling to the bound and failing with the wrong reason is worse
|
|
301
|
+
* than stopping here. So a bad status fails the parse and
|
|
302
|
+
* {@link checkTranscript} turns that into a sentence.
|
|
303
|
+
*
|
|
304
|
+
* `audio_duration` is `z.number()` rather than a coercion, and zod 4's
|
|
305
|
+
* `z.number()` refuses `NaN` and both infinities — the `Number.isFinite` test
|
|
306
|
+
* the hand-written reader carried, which is what stops a non-finite duration
|
|
307
|
+
* reaching `Math.round(… / 60)` in {@link summarize}.
|
|
308
|
+
*/
|
|
309
|
+
const TranscriptBody = z.object({
|
|
310
|
+
status: TranscriptStatus,
|
|
311
|
+
text: z.string().optional().catch(undefined),
|
|
312
|
+
error: z.string().optional().catch(undefined),
|
|
313
|
+
audio_duration: z.number().optional().catch(undefined),
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* The status to NAME when {@link TranscriptBody} refused the body.
|
|
318
|
+
*
|
|
319
|
+
* Reporting the value is the job, so nothing here may throw a second time while
|
|
320
|
+
* reporting the first. `.catch({})` absorbs every shape that is not "an object
|
|
321
|
+
* with a string status" — a body that is not an object at all, a `status` that
|
|
322
|
+
* is a number — and each then arrives in the sentence as the word "undefined",
|
|
323
|
+
* which is a truthful "the provider sent something this desk cannot read" and
|
|
324
|
+
* the same thing the per-field read this replaced said. That `.catch` is also
|
|
325
|
+
* what makes `.parse` legal at the one call site: there is no input left for it
|
|
326
|
+
* to throw on.
|
|
327
|
+
*/
|
|
328
|
+
const ReportedStatus = z
|
|
329
|
+
.object({ status: z.string().optional() })
|
|
330
|
+
.catch({})
|
|
331
|
+
.transform((body) => body.status);
|
|
332
|
+
|
|
281
333
|
/**
|
|
282
334
|
* One undo, and the name it goes by when it fails.
|
|
283
335
|
*
|
|
@@ -636,26 +688,31 @@ export function callbackUrl(token: string): string | undefined {
|
|
|
636
688
|
export async function checkTranscript(id: string): Promise<TranscriptState> {
|
|
637
689
|
const response = await request(`${TRANSCRIPT_ENDPOINT}/${id}`);
|
|
638
690
|
const body = await response.json();
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
691
|
+
// `safeParse`, not `parse`: the only shape this desk refuses is an
|
|
692
|
+
// unrecognised status, and it owes that a sentence naming what arrived rather
|
|
693
|
+
// than a zod issue about an enum.
|
|
694
|
+
const parsed = TranscriptBody.safeParse(body);
|
|
695
|
+
if (!parsed.success) {
|
|
696
|
+
throw new Error(
|
|
697
|
+
`The provider reported an unknown transcript status: ${String(ReportedStatus.parse(body))}`,
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
await report(`Transcript ${parsed.data.status}.`);
|
|
649
702
|
return {
|
|
650
|
-
status,
|
|
703
|
+
status: parsed.data.status,
|
|
651
704
|
// `omitUndefined` rather than a spread-ternary per field: under
|
|
652
705
|
// `exactOptionalPropertyTypes` an absent field and a field set to
|
|
653
706
|
// `undefined` are different types, and this is the SDK's one spelling for
|
|
654
|
-
// the difference.
|
|
707
|
+
// the difference. Still needed after the schema, and for a reason worth
|
|
708
|
+
// knowing: an ABSENT field is absent from zod's output too, but a field
|
|
709
|
+
// whose value the schema `.catch`-ed to `undefined` is PRESENT and holding
|
|
710
|
+
// `undefined` — and this result crosses a queue, where such a key does not
|
|
711
|
+
// survive the trip.
|
|
655
712
|
...omitUndefined({
|
|
656
|
-
text:
|
|
657
|
-
error:
|
|
658
|
-
audioDuration:
|
|
713
|
+
text: parsed.data.text,
|
|
714
|
+
error: parsed.data.error,
|
|
715
|
+
audioDuration: parsed.data.audio_duration,
|
|
659
716
|
}),
|
|
660
717
|
};
|
|
661
718
|
}
|
|
@@ -744,7 +801,7 @@ export async function note(line: string): Promise<void> {
|
|
|
744
801
|
await report(line);
|
|
745
802
|
}
|
|
746
803
|
|
|
747
|
-
// ---- HTTP
|
|
804
|
+
// ---- HTTP -------------------------------------------------------------------
|
|
748
805
|
|
|
749
806
|
/**
|
|
750
807
|
* One authenticated request to the pre-recorded API, with this desk's retry
|
|
@@ -776,15 +833,3 @@ async function request(
|
|
|
776
833
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
777
834
|
});
|
|
778
835
|
}
|
|
779
|
-
|
|
780
|
-
/** A string field of a JSON body, when it really is one. */
|
|
781
|
-
function readString(body: unknown, key: string): string | undefined {
|
|
782
|
-
const value = isRecord(body) ? body[key] : undefined;
|
|
783
|
-
return typeof value === "string" ? value : undefined;
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
/** A number field of a JSON body, when it really is one. */
|
|
787
|
-
function readNumber(body: unknown, key: string): number | undefined {
|
|
788
|
-
const value = isRecord(body) ? body[key] : undefined;
|
|
789
|
-
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
790
|
-
}
|
|
@@ -1615,10 +1615,14 @@ describe("nextPollDelay", () => {
|
|
|
1615
1615
|
});
|
|
1616
1616
|
|
|
1617
1617
|
test("clamps to the floor rather than spinning when the bytes are already there", () => {
|
|
1618
|
-
//
|
|
1619
|
-
//
|
|
1618
|
+
// A remainder of zero, which the `Math.max` below the estimate answers with the
|
|
1619
|
+
// floor — there is no arm of its own for it, and the one that used to be there
|
|
1620
|
+
// was unreachable from the body anyway (a stored segment goes in `ready` and
|
|
1621
|
+
// the loop `continue`s past the sleep). What the floor is FOR is that the body
|
|
1622
|
+
// must not poll faster than the round trip its own sleep costs, which is why it
|
|
1623
|
+
// is 1000ms and not the 250ms that could not sleep at all.
|
|
1620
1624
|
const delay = nextPollDelay(view(60_000, 2000), view(10_000, 1000), planAt(30_000), new Set());
|
|
1621
|
-
expect(delay).toBe(
|
|
1625
|
+
expect(delay).toBe(1000);
|
|
1622
1626
|
});
|
|
1623
1627
|
|
|
1624
1628
|
test("waits for the HEADER window before there is a plan to aim at", () => {
|
|
@@ -1664,15 +1668,21 @@ describe("nextPollDelay", () => {
|
|
|
1664
1668
|
// body plans when `at.size >= HEADER_PROBE_BYTES`, so a delay derived from
|
|
1665
1669
|
// `stored` is answering a different question than the one the loop asks. A
|
|
1666
1670
|
// megabyte has landed in later windows and the header has not arrived.
|
|
1667
|
-
//
|
|
1671
|
+
// 50 bytes/ms against the whole 64 KiB probe window is 1311ms; measured
|
|
1668
1672
|
// against `stored` the remainder is negative and the answer is the floor.
|
|
1673
|
+
//
|
|
1674
|
+
// The rate is half what this case used to carry, and deliberately: at 100
|
|
1675
|
+
// bytes/ms the answer is 656ms, which is UNDER `MIN_POLL_INTERVAL_MS` and
|
|
1676
|
+
// therefore the same 1000 a broken remainder would give — the case would
|
|
1677
|
+
// still pass while measuring nothing. Every case below keeps its answer
|
|
1678
|
+
// clear of both bounds for that reason.
|
|
1669
1679
|
const delay = nextPollDelay(
|
|
1670
|
-
detached(100_000, 0,
|
|
1680
|
+
detached(100_000, 0, 3000),
|
|
1671
1681
|
detached(0, 0, 1000),
|
|
1672
1682
|
undefined,
|
|
1673
1683
|
new Set(),
|
|
1674
1684
|
);
|
|
1675
|
-
expect(delay).toBe(
|
|
1685
|
+
expect(delay).toBe(1311);
|
|
1676
1686
|
});
|
|
1677
1687
|
|
|
1678
1688
|
/**
|
|
@@ -1722,15 +1732,15 @@ describe("nextPollDelay", () => {
|
|
|
1722
1732
|
// saturates the ceiling and gives back the flat 5000ms interval this
|
|
1723
1733
|
// function replaced, once per segment, for the entire recording.
|
|
1724
1734
|
//
|
|
1725
|
-
// Two 8 MiB windows have landed contiguously from byte zero, at ~
|
|
1735
|
+
// Two 8 MiB windows have landed contiguously from byte zero, at ~466
|
|
1726
1736
|
// bytes/ms; the segment ends 866,384 bytes past them.
|
|
1727
1737
|
const delay = nextPollDelay(
|
|
1728
|
-
landed([{ start: 0, end: 2 * PART_BYTES }],
|
|
1738
|
+
landed([{ start: 0, end: 2 * PART_BYTES }], 19_000),
|
|
1729
1739
|
landed([{ start: 0, end: PART_BYTES }], 1000),
|
|
1730
1740
|
planOver([0, 17_643_600]),
|
|
1731
1741
|
new Set(),
|
|
1732
1742
|
);
|
|
1733
|
-
expect(delay).toBe(
|
|
1743
|
+
expect(delay).toBe(1860);
|
|
1734
1744
|
});
|
|
1735
1745
|
|
|
1736
1746
|
test("wakes for the segment CLOSEST to ready, which need not be the earliest", () => {
|
|
@@ -1739,13 +1749,13 @@ describe("nextPollDelay", () => {
|
|
|
1739
1749
|
// has not done. Segment 0 has nothing covering its start and needs the whole
|
|
1740
1750
|
// 20 MB prefix; segment 1 sits inside a window that is 500,000 bytes short.
|
|
1741
1751
|
const delay = nextPollDelay(
|
|
1742
|
-
landed([{ start: 16_000_000, end: 35_500_000 }],
|
|
1752
|
+
landed([{ start: 16_000_000, end: 35_500_000 }], 19_000),
|
|
1743
1753
|
landed([{ start: 16_000_000, end: 27_111_608 }], 1000),
|
|
1744
1754
|
planOver([0, 20_000_000], [16_000_000, 36_000_000]),
|
|
1745
1755
|
new Set(),
|
|
1746
1756
|
);
|
|
1747
|
-
//
|
|
1748
|
-
expect(delay).toBe(
|
|
1757
|
+
// 466 bytes/ms against the 500,000 still missing from segment 1's window.
|
|
1758
|
+
expect(delay).toBe(1073);
|
|
1749
1759
|
});
|
|
1750
1760
|
|
|
1751
1761
|
test("falls back to the prefix for a window that does not cover the segment's START", () => {
|
|
@@ -215,22 +215,43 @@ const POLL_INTERVAL_MS = 5000;
|
|
|
215
215
|
* A poll is one cheap step (the body's own note above the `continue` says so), but
|
|
216
216
|
* it is not free — it is a journal write and, on the platform, a step execution —
|
|
217
217
|
* so a rate estimate that comes out near zero must not turn the loop into a spin.
|
|
218
|
-
*
|
|
219
|
-
*
|
|
218
|
+
*
|
|
219
|
+
* **The comparison that decides this number is the ROUND TRIP of the machinery
|
|
220
|
+
* that implements the sleep, not the latency of a segment's transcription.** It
|
|
221
|
+
* was 250ms on the second reading, which made it DEAD: `ctx.sleep`'s deadline is
|
|
222
|
+
* computed before its journal write is issued and tested after that write comes
|
|
223
|
+
* back, so on the platform — where one journal call was measured at ~558ms mean,
|
|
224
|
+
* p50 ranging 164-796ms — a 250ms sleep has already expired by the time its own
|
|
225
|
+
* write answers. It did not sleep at all, and the loop then polled as fast as the
|
|
226
|
+
* journal would let it. A floor under the round trip is not a floor.
|
|
227
|
+
*
|
|
228
|
+
* 1000ms is above every one of those readings and still an order of magnitude
|
|
229
|
+
* under a single segment's transcription, so nothing is waiting on it.
|
|
220
230
|
*/
|
|
221
|
-
const MIN_POLL_INTERVAL_MS =
|
|
231
|
+
const MIN_POLL_INTERVAL_MS = 1000;
|
|
222
232
|
|
|
223
233
|
/**
|
|
224
234
|
* Consecutive polls with NO new bytes before the run gives up.
|
|
225
235
|
*
|
|
226
236
|
* An upload that died stays incomplete forever, so without a bound the run polls for
|
|
227
|
-
* as long as the world will replay it.
|
|
228
|
-
*
|
|
229
|
-
*
|
|
237
|
+
* as long as the world will replay it.
|
|
238
|
+
*
|
|
239
|
+
* **What one poll COSTS is a delivery, not an interval, and this doc said
|
|
240
|
+
* otherwise.** It read "at {@link POLL_INTERVAL_MS} this is five minutes of
|
|
241
|
+
* silence", which counts the sleep and nothing else. A poll that sleeps also
|
|
242
|
+
* suspends the run, so the wall clock between two polls is the sleep plus a
|
|
243
|
+
* queue round trip plus the next delivery's opening reads — measured on a
|
|
244
|
+
* deployed run at 11-40 seconds. Sixty of those is **20 to 40 minutes**, not
|
|
245
|
+
* five.
|
|
246
|
+
*
|
|
247
|
+
* That is longer than intended and is left as it is: what the bound has to
|
|
248
|
+
* outlast is a stall a live uplink really produces, and erring long costs an
|
|
249
|
+
* abandoned upload some idle deliveries where erring short fails a healthy
|
|
250
|
+
* recording. The number to write down is the honest one.
|
|
230
251
|
*
|
|
231
252
|
* It resets on every byte, so a slow upload is bounded by its own quietest gap
|
|
232
253
|
* rather than by its total length: a two-hour recording on a bad connection is fine
|
|
233
|
-
* as long as something arrives
|
|
254
|
+
* as long as something arrives inside that window.
|
|
234
255
|
*/
|
|
235
256
|
const MAX_IDLE_POLLS = 60;
|
|
236
257
|
|
|
@@ -266,8 +287,9 @@ export type UploadProgressView = {
|
|
|
266
287
|
* When this view was taken, as the step that took it saw the clock.
|
|
267
288
|
*
|
|
268
289
|
* Journaled, which is the only reason the body may read a clock at all: the
|
|
269
|
-
* sleep below is derived from the RATE between
|
|
270
|
-
* body sampled itself would make that derivation diverge
|
|
290
|
+
* sleep below is derived from the RATE between the run's FIRST view and this
|
|
291
|
+
* one, and a value the body sampled itself would make that derivation diverge
|
|
292
|
+
* on a replay. Same rule
|
|
271
293
|
* as every other field here — see the body's own note on why its state is legal.
|
|
272
294
|
*/
|
|
273
295
|
observedAt: number;
|
|
@@ -304,10 +326,20 @@ export async function transcribeStreamFlow(input: { recording: string }, ctx: Wo
|
|
|
304
326
|
// stopped from one whose prefix has not caught up yet.
|
|
305
327
|
let lastStored = -1;
|
|
306
328
|
/**
|
|
307
|
-
* The
|
|
308
|
-
*
|
|
329
|
+
* The FIRST poll, so {@link nextPollDelay} has a baseline to take an AVERAGE
|
|
330
|
+
* rate from. Body state for the same reason the rest is: it came out of a step.
|
|
331
|
+
*
|
|
332
|
+
* It used to be the PREVIOUS poll, and a two-sample difference is not a
|
|
333
|
+
* throughput here: the store publishes bytes an `UPLOAD_PART_BYTES` window at a
|
|
334
|
+
* time, so consecutive polls see either no change at all — which reads as a
|
|
335
|
+
* stall and gives back the flat ceiling — or one whole 8 MiB window, an
|
|
336
|
+
* instantaneous burst rate tens of times the real average that collapses the
|
|
337
|
+
* sleep to its floor. The distribution is bimodal and neither mode is the
|
|
338
|
+
* number the estimate wants. A baseline that never moves measures exactly what
|
|
339
|
+
* the arithmetic below claims to: bytes delivered per millisecond, over the
|
|
340
|
+
* upload so far.
|
|
309
341
|
*/
|
|
310
|
-
let
|
|
342
|
+
let baseline: UploadProgressView | undefined;
|
|
311
343
|
|
|
312
344
|
for (;;) {
|
|
313
345
|
const at = await ctx.step("probeUpload", () => probeUpload(input.recording));
|
|
@@ -315,6 +347,11 @@ export async function transcribeStreamFlow(input: { recording: string }, ctx: Wo
|
|
|
315
347
|
// on a `complete` view, whose prefix is the whole file. Updating it inside a
|
|
316
348
|
// branch is how it used to end up describing whichever poll last had work.
|
|
317
349
|
lastSize = at.size;
|
|
350
|
+
// HERE, not after the sleep below, and that placement is the whole bug the
|
|
351
|
+
// baseline replaced: the `continue` on a batch of ready segments skipped the
|
|
352
|
+
// old `previous = at`, so the next rate was computed against a view taken
|
|
353
|
+
// before the batch. Set once, before any branch can be taken.
|
|
354
|
+
baseline ??= at;
|
|
318
355
|
|
|
319
356
|
// The header has to be present before anything can be planned, and it is the
|
|
320
357
|
// first thing to arrive. `complete` also qualifies, for a recording shorter
|
|
@@ -381,8 +418,7 @@ export async function transcribeStreamFlow(input: { recording: string }, ctx: Wo
|
|
|
381
418
|
// Sleep until the next segment should HAVE landed, rather than for a fixed
|
|
382
419
|
// interval — see `nextPollDelay`. Both arguments are journaled step results,
|
|
383
420
|
// so a replay computes the same delay from the same two samples.
|
|
384
|
-
await ctx.sleep("poll", nextPollDelay(at,
|
|
385
|
-
previous = at;
|
|
421
|
+
await ctx.sleep("poll", nextPollDelay(at, baseline, plan, done));
|
|
386
422
|
}
|
|
387
423
|
|
|
388
424
|
const finished = plan;
|
|
@@ -429,8 +465,16 @@ export async function probeUpload(id: string): Promise<UploadProgressView> {
|
|
|
429
465
|
* The flat {@link POLL_INTERVAL_MS} this replaced is wrong in both directions on a
|
|
430
466
|
* slow uplink: too long when a segment is seconds away, and equally too long when
|
|
431
467
|
* it is a minute away, so the run discovers work late and then asks again pointlessly.
|
|
432
|
-
*
|
|
433
|
-
* un-transcribed segment needs, and the difference is a wait with a
|
|
468
|
+
* The run's FIRST poll and its latest one give a byte RATE, the plan gives the byte
|
|
469
|
+
* offset the next un-transcribed segment needs, and the difference is a wait with a
|
|
470
|
+
* reason.
|
|
471
|
+
*
|
|
472
|
+
* **`baseline` is the first poll of the run and never moves**, so what this reads is
|
|
473
|
+
* the AVERAGE throughput rather than a first difference between two adjacent polls.
|
|
474
|
+
* That is not a refinement: bytes are published an `UPLOAD_PART_BYTES` window at a
|
|
475
|
+
* time, so a two-sample difference is 0 or one whole 8 MiB window and never the
|
|
476
|
+
* rate — see the body's own note where the baseline is set. The arithmetic below is
|
|
477
|
+
* unchanged, which is why it is the CALLER that had the bug.
|
|
434
478
|
*
|
|
435
479
|
* Every input is a journaled step result — both views, and a plan derived from one —
|
|
436
480
|
* so a replay computes the identical delay. That is the whole reason
|
|
@@ -445,15 +489,16 @@ export async function probeUpload(id: string): Promise<UploadProgressView> {
|
|
|
445
489
|
*/
|
|
446
490
|
export function nextPollDelay(
|
|
447
491
|
at: UploadProgressView,
|
|
448
|
-
|
|
492
|
+
baseline: UploadProgressView | undefined,
|
|
449
493
|
plan: StreamPlan | undefined,
|
|
450
494
|
done: ReadonlySet<number>,
|
|
451
495
|
): number {
|
|
452
|
-
// No
|
|
453
|
-
// from. The first sleep of every run takes this arm
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
496
|
+
// No baseline, or a clock that has not advanced past it: nothing to derive a
|
|
497
|
+
// rate from. The first sleep of every run takes this arm, the baseline BEING
|
|
498
|
+
// that poll.
|
|
499
|
+
const elapsedMs = baseline ? at.observedAt - baseline.observedAt : 0;
|
|
500
|
+
if (!baseline || elapsedMs <= 0) return POLL_INTERVAL_MS;
|
|
501
|
+
const bytesPerMs = (at.stored - baseline.stored) / elapsedMs;
|
|
457
502
|
// A stalled or shrinking upload has no arrival to predict. `MAX_IDLE_POLLS` is
|
|
458
503
|
// what ends that run; this only declines to guess about it.
|
|
459
504
|
if (bytesPerMs <= 0) return POLL_INTERVAL_MS;
|
|
@@ -476,7 +521,13 @@ export function nextPollDelay(
|
|
|
476
521
|
// Every segment is already stored: the loop is waiting on `complete`, which is a
|
|
477
522
|
// flag the uploader sets rather than bytes to extrapolate.
|
|
478
523
|
if (remaining === undefined) return POLL_INTERVAL_MS;
|
|
479
|
-
|
|
524
|
+
// There is no `remaining <= 0` arm, and there used to be one returning the
|
|
525
|
+
// floor. It was UNREACHABLE from the body — `bytesUntilStored` answers 0 only
|
|
526
|
+
// when `segmentStored` does, which would have put the segment in `ready` and
|
|
527
|
+
// taken the `continue` above, and the no-plan arm is only taken while
|
|
528
|
+
// `at.size < HEADER_PROBE_BYTES` — and REDUNDANT besides: `Math.ceil` of a
|
|
529
|
+
// non-positive quotient is non-positive, so the `Math.max` below already
|
|
530
|
+
// answers the floor for it. Deleting it changes no answer for any input.
|
|
480
531
|
return Math.min(
|
|
481
532
|
POLL_INTERVAL_MS,
|
|
482
533
|
Math.max(MIN_POLL_INTERVAL_MS, Math.ceil(remaining / bytesPerMs)),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alexkroman1/aai-cli",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "13.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -50,18 +50,18 @@
|
|
|
50
50
|
"ofetch": "^1.5.1",
|
|
51
51
|
"p-debounce": "^5.1.0",
|
|
52
52
|
"p-timeout": "^7.0.1",
|
|
53
|
-
"vite": "^8.2.
|
|
54
|
-
"zod": "^4.4
|
|
55
|
-
"@alexkroman1/aai": "
|
|
56
|
-
"@alexkroman1/aai-runtime": "
|
|
57
|
-
"@alexkroman1/aai-ui": "
|
|
53
|
+
"vite": "^8.2.2",
|
|
54
|
+
"zod": "^4.5.4",
|
|
55
|
+
"@alexkroman1/aai": "13.0.0",
|
|
56
|
+
"@alexkroman1/aai-runtime": "13.0.0",
|
|
57
|
+
"@alexkroman1/aai-ui": "13.0.0"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"playwright": "^1.62.1",
|
|
61
61
|
"tsdown": "^0.22.14",
|
|
62
|
-
"verdaccio": "^6.
|
|
63
|
-
"vitest": "^4.1.
|
|
64
|
-
"aai-templates": "0.3.
|
|
62
|
+
"verdaccio": "^6.10.1",
|
|
63
|
+
"vitest": "^4.1.11",
|
|
64
|
+
"aai-templates": "0.3.10"
|
|
65
65
|
},
|
|
66
66
|
"peerDependencies": {
|
|
67
67
|
"vitest": "^4.1.10"
|