@alexkroman1/aai-cli 9.2.0 → 10.0.1
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/{_bundler-CDuYl5Gb.mjs → _bundler-B31MqYaD.mjs} +7 -33
- package/dist/_bundler.d.ts +3 -41
- package/dist/{_dev-server-BAR8Ya43.mjs → _dev-server-Bo5dSBzQ.mjs} +140 -44
- package/dist/_dev-server.d.ts +1 -8
- package/dist/_dev-vite-config.d.ts +21 -0
- package/dist/{_init-BNotRgV1.mjs → _init-BURbdHNM.mjs} +1 -1
- package/dist/{_slug-api--ZBmIGH-.mjs → _slug-api-CfGQq1Tt.mjs} +1 -1
- package/dist/{_templates-4WcKOjS5.mjs → _templates-0Zr2z6yA.mjs} +37 -2
- package/dist/_templates.d.ts +29 -0
- package/dist/{build-DvDdSwAS.mjs → build-DKC2e9o_.mjs} +1 -2
- package/dist/cli.mjs +46 -22
- package/dist/{delete-8pjH8n8n.mjs → delete-X_CRgCf6.mjs} +1 -1
- package/dist/{deploy-CjU3e-3P.mjs → deploy-CchW5pmw.mjs} +2 -2
- package/dist/{dev-dFzdvwa7.mjs → dev-BfVl1uBA.mjs} +1 -1
- package/dist/{init-DK7GpIF9.mjs → init-BRfoc2EK.mjs} +2 -2
- package/dist/{logs-D2Yhqe2w.mjs → logs-hWnJ66Bl.mjs} +2 -2
- package/dist/scaffold/CLAUDE.md +162 -54
- package/dist/scaffold/package.json +4 -6
- package/dist/scaffold/server.mjs +12 -23
- package/dist/{secret-CwgDLpSy.mjs → secret-B-3T5tvr.mjs} +2 -2
- package/dist/{studio-D3-HWIfk.mjs → studio-wVWNLREn.mjs} +4 -3
- package/dist/templates/call-audit/agent.eval.test.ts +1 -1
- package/dist/templates/call-audit/agent.test.ts +80 -8
- package/dist/templates/call-audit/client.tsx +87 -3
- package/dist/templates/call-audit/workflows/audit.ts +52 -32
- package/dist/templates/call-audit/workflows/ingest.ts +15 -15
- package/dist/templates/call-audit/workflows/media.ts +1 -1
- package/dist/templates/call-audit/workflows/summarize.ts +0 -4
- package/dist/templates/link-digest/agent.eval.test.ts +4 -2
- package/dist/templates/link-digest/agent.test.ts +25 -5
- package/dist/templates/link-digest/client.tsx +65 -7
- package/dist/templates/link-digest/workflows/digest.ts +39 -29
- package/dist/templates/podcast-digest/agent.eval.test.ts +5 -5
- package/dist/templates/podcast-digest/agent.test.ts +108 -2
- package/dist/templates/podcast-digest/client.tsx +89 -8
- package/dist/templates/podcast-digest/workflows/digest.ts +67 -48
- package/dist/templates/podcast-digest/workflows/feeds.ts +1 -4
- package/dist/templates/podcast-digest/workflows/slack.ts +5 -6
- package/dist/templates/recap-workflow/agent.eval.test.ts +72 -8
- package/dist/templates/recap-workflow/agent.test.ts +323 -83
- package/dist/templates/recap-workflow/shared.ts +4 -3
- package/dist/templates/recap-workflow/workflows/recap.ts +322 -86
- package/dist/templates/recap-workflow/workflows/tokens.ts +21 -0
- package/dist/templates/redline/agent.eval.test.ts +1 -1
- package/dist/templates/redline/agent.test.ts +1 -1
- package/dist/templates/redline/client.tsx +83 -3
- package/dist/templates/redline/workflows/redline.ts +26 -23
- package/dist/templates/research-workflow/agent.eval.test.ts +9 -8
- package/dist/templates/research-workflow/agent.test.ts +32 -6
- package/dist/templates/research-workflow/agent.ts +4 -4
- package/dist/templates/research-workflow/shared.ts +4 -3
- package/dist/templates/research-workflow/workflows/prompts.ts +1 -1
- package/dist/templates/research-workflow/workflows/research.ts +35 -38
- package/dist/templates/spoken-summary/agent.eval.test.ts +3 -3
- package/dist/templates/spoken-summary/agent.test.ts +10 -8
- package/dist/templates/spoken-summary/client.tsx +72 -3
- package/dist/templates/spoken-summary/workflows/summarize.ts +25 -21
- package/dist/templates/spoken-summary/workflows/transcribe.ts +15 -18
- package/dist/templates/transcription-workflow/agent.eval.test.ts +1 -1
- package/dist/templates/transcription-workflow/agent.test.ts +626 -14
- package/dist/templates/transcription-workflow/agent.ts +6 -4
- package/dist/templates/transcription-workflow/client.tsx +106 -308
- package/dist/templates/transcription-workflow/recover.test.ts +141 -0
- package/dist/templates/transcription-workflow/recover.ts +130 -0
- package/dist/templates/transcription-workflow/run-panel.tsx +203 -0
- package/dist/templates/transcription-workflow/total-latency.tsx +144 -0
- package/dist/templates/transcription-workflow/workflows/batch.ts +21 -24
- package/dist/templates/transcription-workflow/workflows/downsample.ts +273 -0
- package/dist/templates/transcription-workflow/workflows/normalize.ts +17 -40
- package/dist/templates/transcription-workflow/workflows/stream.ts +177 -24
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +69 -32
- package/dist/templates/transcription-workflow/workflows/wav.ts +15 -4
- package/dist/worker-bundler.d.ts +0 -14
- package/dist/worker-bundler.mjs +149 -1
- package/dist/{workflow-BflATMmU.mjs → workflow-BBu5oEDw.mjs} +84 -10
- package/dist/workflow.d.ts +1 -0
- package/package.json +4 -6
- package/dist/_workflow-scan.d.ts +0 -108
- package/dist/worker-bundler-CCVEDjm1.mjs +0 -650
- package/dist/workflow-bundler.d.ts +0 -138
- package/dist/{_studio-CKrsixd0.mjs → _studio-B1waXMuR.mjs} +1 -1
|
@@ -63,16 +63,19 @@
|
|
|
63
63
|
* `output` exists only when the last segment does.
|
|
64
64
|
*/
|
|
65
65
|
|
|
66
|
+
import type { WorkflowCtx } from "@alexkroman1/aai";
|
|
66
67
|
import {
|
|
67
68
|
emit,
|
|
68
69
|
encodeWav,
|
|
69
70
|
mapConcurrent,
|
|
70
71
|
readUpload,
|
|
71
72
|
report,
|
|
73
|
+
requireCompleteUpload,
|
|
72
74
|
uploadInfo,
|
|
73
75
|
} from "@alexkroman1/aai/step";
|
|
74
76
|
import { throwFatalStepError } from "@alexkroman1/aai/step-errors";
|
|
75
77
|
import { countWords, formatDuration, plural } from "@alexkroman1/aai/utils";
|
|
78
|
+
import { downsampleSegment, requestFormat } from "./downsample.ts";
|
|
76
79
|
import { normalizeRecording } from "./normalize.ts";
|
|
77
80
|
import { stitchTranscript, TRANSCRIPT_STREAM, type TranscriptChunk } from "./stitch.ts";
|
|
78
81
|
import { elapsed, timed, transcribeWav } from "./sync-api.ts";
|
|
@@ -168,15 +171,18 @@ export const BYTES_IN_FLIGHT = 640 * 1024 * 1024;
|
|
|
168
171
|
* The table above was measured under the old per-round barrier. Re-measuring it is
|
|
169
172
|
* worth doing before this number moves again: the window makes a wide fan-out
|
|
170
173
|
* cheaper at the tail, which if anything argues for a HIGHER knee. *
|
|
171
|
-
* **What EXECUTES at this width is the
|
|
174
|
+
* **What EXECUTES at this width is the engine's call, not this number's.**
|
|
172
175
|
* `mapConcurrent` bounds how many step calls the body has in flight; how many
|
|
173
|
-
* run at once is
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
176
|
+
* run at once is `DEFAULT_STEP_CONCURRENCY` (`aai-runtime`), which is **16** —
|
|
177
|
+
* measured against a real microVM at Modal's guaranteed reservation, where a
|
|
178
|
+
* concurrent segment of 48 kHz stereo costs 26.1 MB. So a width above 16 is
|
|
179
|
+
* inert on a stock deployment while still costing a queued job per item, and
|
|
180
|
+
* this number is the FAR SIDE's knee: the one to use once an operator has
|
|
181
|
+
* raised `AAI_WORKFLOW_STEP_CONCURRENCY` for a larger guest. It was three,
|
|
182
|
+
* inherited from graphile-worker and never measured, which made every number
|
|
183
|
+
* in the table above unreachable. See "The WINDOW is not the concurrency" in
|
|
184
|
+
* `@alexkroman1/aai/step`'s `mapConcurrent`; the numbers above were measured
|
|
185
|
+
* against the endpoint and say nothing about that layer.
|
|
180
186
|
*/
|
|
181
187
|
export const MAX_SEGMENT_CONCURRENCY = 32;
|
|
182
188
|
|
|
@@ -199,7 +205,13 @@ export const MAX_SEGMENT_CONCURRENCY = 32;
|
|
|
199
205
|
* `stepFetch` pins.
|
|
200
206
|
*/
|
|
201
207
|
export function segmentConcurrency(format: WavFormat): number {
|
|
202
|
-
|
|
208
|
+
// The format that will be SENT, not the one that was cut. The budget is bytes
|
|
209
|
+
// UPLOADING and `transcribeSegment` normalizes each window first, so asking the
|
|
210
|
+
// source format would price a 48 kHz stereo segment at 17.66 MB when 2.94 MB
|
|
211
|
+
// goes on the wire — six times too cautious, and drifting the moment either
|
|
212
|
+
// side of that pair changes. Both derive it from `requestFormat`.
|
|
213
|
+
const perSegment =
|
|
214
|
+
bytesPerSecond(requestFormat(format)) * (SEGMENT_SECONDS + SEGMENT_OVERLAP_SECONDS);
|
|
203
215
|
if (perSegment <= 0) return MAX_SEGMENT_CONCURRENCY;
|
|
204
216
|
return Math.max(1, Math.min(MAX_SEGMENT_CONCURRENCY, Math.floor(BYTES_IN_FLIGHT / perSegment)));
|
|
205
217
|
}
|
|
@@ -236,9 +248,7 @@ export type SegmentTranscript = {
|
|
|
236
248
|
* The input is what `POST /workflows/runs` carries — see `agent.ts` for the
|
|
237
249
|
* schema it is validated against before a run exists.
|
|
238
250
|
*/
|
|
239
|
-
export async function transcribeFlow(input: { recording: string }) {
|
|
240
|
-
"use workflow";
|
|
241
|
-
|
|
251
|
+
export async function transcribeFlow(input: { recording: string }, ctx: WorkflowCtx) {
|
|
242
252
|
// Both at once: neither needs the other, and issued together they are one
|
|
243
253
|
// round trip instead of two before any audio is read. The ORDER is still a
|
|
244
254
|
// pure function of this line — the two calls go out synchronously, left to
|
|
@@ -247,12 +257,20 @@ export async function transcribeFlow(input: { recording: string }) {
|
|
|
247
257
|
// The clock starts before the conversion rather than after it, because a
|
|
248
258
|
// reader comparing the three flows over one file is comparing what the desk
|
|
249
259
|
// COST them, and re-encoding an m4a is part of that.
|
|
250
|
-
|
|
260
|
+
// `maxAttempts: 6` was `normalizeRecording.maxRetries = 5`. More than the
|
|
261
|
+
// default 3, and not because a conversion is flaky — a corrupt file fails
|
|
262
|
+
// identically forever, and `throwFfmpegStepError` is what stops the engine
|
|
263
|
+
// retrying that. It is the two I/O halves that are worth another attempt: this
|
|
264
|
+
// step reads a whole recording out of the store and writes a whole one back.
|
|
265
|
+
const [startedAt, ready] = await Promise.all([
|
|
266
|
+
ctx.step("startClock", () => startClock()),
|
|
267
|
+
ctx.step("normalizeRecording", () => normalizeRecording(input.recording), { maxAttempts: 6 }),
|
|
268
|
+
]);
|
|
251
269
|
|
|
252
270
|
// `ready.recording` from here on, not `input.recording`: a converted file is a
|
|
253
271
|
// DIFFERENT upload, and cutting the original by offsets planned against the
|
|
254
272
|
// converted one is a fan-out of garbage that still reports success.
|
|
255
|
-
const plan = await splitRecording(ready.recording);
|
|
273
|
+
const plan = await ctx.step("splitRecording", () => splitRecording(ready.recording));
|
|
256
274
|
|
|
257
275
|
// One step per segment, bounded, in an order a replay reproduces exactly.
|
|
258
276
|
// A failed segment fails the RUN, deliberately: every sibling that finished is
|
|
@@ -260,13 +278,20 @@ export async function transcribeFlow(input: { recording: string }) {
|
|
|
260
278
|
// what is missing, where catching here to salvage a partial transcript would
|
|
261
279
|
// return a recording with a silent hole in it and report success.
|
|
262
280
|
const parts = await mapConcurrent(plan.segments, segmentConcurrency(plan.format), (segment) =>
|
|
263
|
-
transcribeSegment
|
|
281
|
+
// `maxAttempts: 6` was `transcribeSegment.maxRetries = 5` — more than the
|
|
282
|
+
// default 3 because a rate limit is the expected failure here, and a segment
|
|
283
|
+
// that 429s is not a segment that is wrong.
|
|
284
|
+
ctx.step("transcribeSegment", () => transcribeSegment(ready.recording, plan.format, segment), {
|
|
285
|
+
maxAttempts: 6,
|
|
286
|
+
}),
|
|
264
287
|
);
|
|
265
288
|
|
|
266
289
|
// The ORIGINAL id, and only here: `mergeTranscript` uses it for the filename a
|
|
267
290
|
// reader sees, and `standup.m4a` is the recording they uploaded — where the
|
|
268
291
|
// converted copy is an artifact of how the desk works.
|
|
269
|
-
return await mergeTranscript
|
|
292
|
+
return await ctx.step("mergeTranscript", () =>
|
|
293
|
+
mergeTranscript(input.recording, plan.durationMs, parts, startedAt),
|
|
294
|
+
);
|
|
270
295
|
}
|
|
271
296
|
|
|
272
297
|
/**
|
|
@@ -283,10 +308,14 @@ export async function splitRecording(uploadId: string): Promise<{
|
|
|
283
308
|
segments: Segment[];
|
|
284
309
|
durationMs: number;
|
|
285
310
|
}> {
|
|
286
|
-
|
|
287
|
-
|
|
311
|
+
// The whole file, refused if it is still arriving. `info.size` is the readable
|
|
312
|
+
// PREFIX, and it is what the segment plan's width is derived from — so against a
|
|
313
|
+
// half-arrived recording this planned a fan-out over the first half and the run
|
|
314
|
+
// returned a transcript of it, reporting success. `stream.ts` is the flow for a
|
|
315
|
+
// recording that is still landing; this one wants all of it.
|
|
316
|
+
const stored = await requireCompleteUpload(uploadId);
|
|
288
317
|
const head = await readUpload(uploadId, { end: HEADER_PROBE_BYTES });
|
|
289
|
-
const format = fatalOnUnsupported(() => parseWav(head.bytes,
|
|
318
|
+
const format = fatalOnUnsupported(() => parseWav(head.bytes, stored.size));
|
|
290
319
|
const segments = fatalOnUnsupported(() => planSegments(format));
|
|
291
320
|
const durationMs = segments.at(-1)?.endMs ?? 0;
|
|
292
321
|
|
|
@@ -308,8 +337,6 @@ export async function transcribeSegment(
|
|
|
308
337
|
format: WavFormat,
|
|
309
338
|
segment: Segment,
|
|
310
339
|
): Promise<SegmentTranscript> {
|
|
311
|
-
"use step";
|
|
312
|
-
|
|
313
340
|
// One line per segment, which is what makes the fan-out legible to a page: the
|
|
314
341
|
// status is `running` for the whole thing, so without this a sixty-segment
|
|
315
342
|
// recording and a one-segment recording look identical while they run.
|
|
@@ -338,9 +365,29 @@ export async function transcribeSegment(
|
|
|
338
365
|
// template's: a `WavFormat` is structurally a `PcmFormat`, and 22 lines of
|
|
339
366
|
// `DataView` writes with a comment about which of the two declared lengths a
|
|
340
367
|
// decoder trusts is not a thing worth a second copy of.
|
|
368
|
+
// Down to 16 kHz mono BEFORE the header goes on, because the endpoint's budget
|
|
369
|
+
// is 30 seconds of wall clock and that covers the upload. At 48 kHz stereo this
|
|
370
|
+
// window is 17.66 MB and the same audio is 2.94 MB normalized — six times the
|
|
371
|
+
// bytes against a fixed deadline, which is what turns a slow segment into a
|
|
372
|
+
// `504 request exceeded 30.0s` and then into a failed run. Inert on the classic
|
|
373
|
+
// flow, where `normalizeRecording` already converted the whole file; see
|
|
374
|
+
// `downsample.ts` for why the streaming flow cannot do the same.
|
|
375
|
+
//
|
|
376
|
+
// Through `fatalOnUnsupported` for the same reason `planStreamed` reads its
|
|
377
|
+
// header through it: `parseWav` admits any bit depth whose block align is
|
|
378
|
+
// positive, and `downsampleSegment` can serve only four of them — so a
|
|
379
|
+
// recording the desk could cut but cannot send raises
|
|
380
|
+
// `UnsupportedRecordingError` here, and a plain throw would spend all six
|
|
381
|
+
// attempts re-reading this window out of the upload store to arrive at the
|
|
382
|
+
// identical answer. BOTH flows can reach it, which is newer than it looks:
|
|
383
|
+
// the check used to hang off the resampler, so a 12-bit recording already at
|
|
384
|
+
// 16 kHz mono — light for both flows, and therefore converted by neither —
|
|
385
|
+
// sailed past it into an unclassified `RangeError` from `encodeWav`.
|
|
386
|
+
const light = fatalOnUnsupported(() => downsampleSegment(audio.bytes, format));
|
|
387
|
+
|
|
341
388
|
const { value: text, ms } = await timed(() =>
|
|
342
389
|
transcribeWav(
|
|
343
|
-
encodeWav(
|
|
390
|
+
encodeWav(light.bytes, light.format),
|
|
344
391
|
`segment-${segment.index}.wav`,
|
|
345
392
|
`Segment ${segment.index} (${formatDuration(segment.startMs)})`,
|
|
346
393
|
),
|
|
@@ -364,12 +411,6 @@ export async function transcribeSegment(
|
|
|
364
411
|
return { index: segment.index, text };
|
|
365
412
|
}
|
|
366
413
|
|
|
367
|
-
/**
|
|
368
|
-
* Retries beyond the default 3, because a rate limit is the expected failure and
|
|
369
|
-
* a segment that 429s is not a segment that is wrong.
|
|
370
|
-
*/
|
|
371
|
-
transcribeSegment.maxRetries = 5;
|
|
372
|
-
|
|
373
414
|
/**
|
|
374
415
|
* Stitch the segments into one transcript.
|
|
375
416
|
*
|
|
@@ -385,8 +426,6 @@ export async function mergeTranscript(
|
|
|
385
426
|
parts: readonly SegmentTranscript[],
|
|
386
427
|
startedAt: number,
|
|
387
428
|
): Promise<Transcript> {
|
|
388
|
-
"use step";
|
|
389
|
-
|
|
390
429
|
await report(`Stitching ${parts.length} ${plural(parts.length, "segment")} together.`);
|
|
391
430
|
|
|
392
431
|
// `mapConcurrent` resolves in ITEM order however the calls settled, so this is
|
|
@@ -427,8 +466,6 @@ export async function mergeTranscript(
|
|
|
427
466
|
* which needs one number measured one way.
|
|
428
467
|
*/
|
|
429
468
|
export async function startClock(): Promise<number> {
|
|
430
|
-
"use step";
|
|
431
|
-
|
|
432
469
|
return Date.now();
|
|
433
470
|
}
|
|
434
471
|
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
* The pure half of the transcription desk: reading a WAV header, deciding where
|
|
4
4
|
* to cut, and writing a header back over each cut.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* Nothing in this file is a step or a body, and that is legal — a body is
|
|
7
|
+
* whatever is handed a `ctx`, so an ordinary module can sit beside one. It is a
|
|
8
|
+
* separate
|
|
9
9
|
* file because everything here is a pure function of a journaled value, which is
|
|
10
10
|
* what makes it testable without a run at all: `transcribe.ts`'s spec drives
|
|
11
11
|
* these directly.
|
|
@@ -43,7 +43,18 @@
|
|
|
43
43
|
* so they keep naming the `ffmpeg` line that fixes the file.
|
|
44
44
|
*/
|
|
45
45
|
|
|
46
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* What the sync endpoint will accept in one request.
|
|
48
|
+
*
|
|
49
|
+
* **Measured against the live endpoint, the binding limit is a 30-second WALL
|
|
50
|
+
* CLOCK budget rather than this**, and that budget covers the upload as well as
|
|
51
|
+
* the transcription: a 92-second segment that is 17.66 MB comes back
|
|
52
|
+
* `504 — request exceeded 30.0s` well inside the documented cap. The constant is
|
|
53
|
+
* left alone because it is what the service DOCUMENTS and the discrepancy is
|
|
54
|
+
* unconfirmed; what closes the gap today is `downsample.ts`, which makes each
|
|
55
|
+
* request six times lighter so the deadline stops binding. Revisit both together
|
|
56
|
+
* once the real cap is settled — this number is what `planSegments` cuts to.
|
|
57
|
+
*/
|
|
47
58
|
export const MAX_SEGMENT_SECONDS = 120;
|
|
48
59
|
|
|
49
60
|
/**
|
package/dist/worker-bundler.d.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { type PluginOption } from "vite";
|
|
2
|
-
import { type WorkflowBundleOutput } from "./workflow-bundler.ts";
|
|
3
2
|
/**
|
|
4
3
|
* Options for worker bundling.
|
|
5
4
|
*
|
|
@@ -25,19 +24,6 @@ export type BuildWorkerOptions = {
|
|
|
25
24
|
* rebuild would turn the watch loop from sub-second into multi-second.
|
|
26
25
|
*/
|
|
27
26
|
runtime?: boolean;
|
|
28
|
-
/**
|
|
29
|
-
* The project's compiled workflows, when it declares any.
|
|
30
|
-
*
|
|
31
|
-
* Embedded in the worker as two string exports rather than shipped as extra
|
|
32
|
-
* files, because the guest's `bundle/load` contract is ONE ESM string. See
|
|
33
|
-
* `wrapperEntrySource`.
|
|
34
|
-
*
|
|
35
|
-
* It also switches on the client transform (`workflowClientPlugin`), which is
|
|
36
|
-
* what puts a `workflowId` on the agent's own copy of each body. Passing the
|
|
37
|
-
* strings without it produces a bundle that serves every workflow route and
|
|
38
|
-
* cannot start a run.
|
|
39
|
-
*/
|
|
40
|
-
workflows?: WorkflowBundleOutput | undefined;
|
|
41
27
|
};
|
|
42
28
|
/**
|
|
43
29
|
* Bundle agent.ts into a single ESM string for the sandbox worker.
|
package/dist/worker-bundler.mjs
CHANGED
|
@@ -1,3 +1,151 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { i as errorCode } from "./_utils-B8QmtFhK.mjs";
|
|
3
|
+
import { n as withPreservedNodeEnv } from "./_vite-env-BNveawd1.mjs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import fs from "node:fs/promises";
|
|
6
|
+
import { invariant } from "@alexkroman1/aai/internal";
|
|
7
|
+
import { build } from "vite";
|
|
8
|
+
//#region worker-bundler.ts
|
|
9
|
+
/**
|
|
10
|
+
* Generated wrapper entry, written under `.aai/` for the duration of the
|
|
11
|
+
* build (the CLI's own scratch dir — dot-paths are ignored by the dev
|
|
12
|
+
* watcher, and the studio's workspace materialization never writes there).
|
|
13
|
+
*/
|
|
14
|
+
const WRAPPER_ENTRY_REL = path.join(".aai", "worker-entry.ts");
|
|
15
|
+
/** Extensions a tool module may be authored in (mirrors the SDK's registry). */
|
|
16
|
+
const TOOL_MODULE_EXT_RE = /\.(?:m?ts|tsx)$/;
|
|
17
|
+
/** A co-located spec is not a tool. */
|
|
18
|
+
const TOOL_SPEC_RE = /\.(?:test|spec)\.[^.]+$/;
|
|
19
|
+
/**
|
|
20
|
+
* The project's `tools/` directory, as file names relative to it.
|
|
21
|
+
*
|
|
22
|
+
* **This is the whole of "discovery", and it happens HERE because the guest has
|
|
23
|
+
* no filesystem.** A sandbox is handed one ESM string, so the only place a
|
|
24
|
+
* directory can be turned into modules is where the bundle is assembled — the
|
|
25
|
+
* same lowering eve does (`readdir` → static import list, which the bundler then
|
|
26
|
+
* follows). The names are validated by `toolRegistry` at bundle-evaluation time
|
|
27
|
+
* rather than here, so one implementation owns the rules.
|
|
28
|
+
*
|
|
29
|
+
* Sorted, so the emitted entry is byte-stable for a given directory: an entry
|
|
30
|
+
* that reordered per readdir would change the bundle hash for no reason.
|
|
31
|
+
*/
|
|
32
|
+
async function discoverToolFiles(cwd) {
|
|
33
|
+
const root = path.join(cwd, "tools");
|
|
34
|
+
let entries;
|
|
35
|
+
try {
|
|
36
|
+
entries = await fs.readdir(root, {
|
|
37
|
+
withFileTypes: true,
|
|
38
|
+
recursive: true
|
|
39
|
+
});
|
|
40
|
+
} catch (err) {
|
|
41
|
+
if (errorCode(err) === "ENOENT") return [];
|
|
42
|
+
throw err;
|
|
43
|
+
}
|
|
44
|
+
return entries.filter((e) => e.isFile() && TOOL_MODULE_EXT_RE.test(e.name) && !TOOL_SPEC_RE.test(e.name)).map((e) => path.relative(root, path.join(e.parentPath, e.name)).split(path.sep).join("/")).sort();
|
|
45
|
+
}
|
|
46
|
+
/** The prose slot: one file, beside `agent.ts`, named by convention. */
|
|
47
|
+
const SYSTEM_PROMPT_FILE = "system-prompt.md";
|
|
48
|
+
/**
|
|
49
|
+
* Whether the project keeps its system prompt in a file.
|
|
50
|
+
*
|
|
51
|
+
* The other half of "a file beside `agent.ts` can BE the thing", and it happens
|
|
52
|
+
* here for the same reason `discoverToolFiles` does — the guest has no
|
|
53
|
+
* filesystem, so the read belongs where the bundle is assembled. What the file
|
|
54
|
+
* MEANS is `withSystemPrompt`'s to decide (the author may have imported and
|
|
55
|
+
* composed it, in which case discovery must not apply it twice); this only
|
|
56
|
+
* answers whether there is one.
|
|
57
|
+
*/
|
|
58
|
+
async function hasSystemPromptFile(cwd) {
|
|
59
|
+
const nested = path.join(cwd, "system-prompt");
|
|
60
|
+
if ((await fs.stat(nested).catch(() => void 0))?.isDirectory() === true) throw new Error(`${nested} is a directory. A system prompt is ONE file — rename it to ${SYSTEM_PROMPT_FILE}, or import the pieces yourself and compose them into \`systemPrompt\`. There is deliberately no concatenation order for a directory.`);
|
|
61
|
+
try {
|
|
62
|
+
return (await fs.stat(path.join(cwd, SYSTEM_PROMPT_FILE))).isFile();
|
|
63
|
+
} catch (err) {
|
|
64
|
+
if (errorCode(err) === "ENOENT") return false;
|
|
65
|
+
throw err;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function wrapperEntrySource(runtime, toolFiles, systemPromptFile) {
|
|
69
|
+
const toolImports = toolFiles.map((file, i) => `import * as __aaiTool${i} from "../tools/${file}";`).join("\n");
|
|
70
|
+
const toolEntries = toolFiles.map((file, i) => ` ${JSON.stringify(`tools/${file}`)}: __aaiTool${i},`).join("\n");
|
|
71
|
+
return `import def from "../agent.ts";
|
|
72
|
+
import { agentToolsToSchemas, toAgentConfig, toolRegistry, withSystemPrompt, withTools } from "@alexkroman1/aai/manifest";
|
|
73
|
+
${runtime ? `import { createRuntime } from "@alexkroman1/aai-runtime";` : ""}
|
|
74
|
+
${systemPromptFile ? `import __aaiSystemPrompt from "../${SYSTEM_PROMPT_FILE}?raw";` : ""}
|
|
75
|
+
${toolImports}
|
|
76
|
+
// A tool's name is its file name. The map is built here rather than written in
|
|
77
|
+
// agent.ts, so a file that exists is a tool the model can call — there is no
|
|
78
|
+
// registration step to forget.
|
|
79
|
+
//
|
|
80
|
+
// \`system-prompt.md\` arrives the same way, and the \`?raw\` lives HERE rather
|
|
81
|
+
// than in the author's own \`agent.ts\`: it is a Vite convention, and the whole
|
|
82
|
+
// point of generating this entry is that a bundler feature never has to appear
|
|
83
|
+
// in user-authored space.
|
|
84
|
+
const __aaiAgent = ${systemPromptFile ? "withSystemPrompt(" : ""}withTools(
|
|
85
|
+
def,
|
|
86
|
+
toolRegistry({
|
|
87
|
+
${toolEntries}
|
|
88
|
+
}),
|
|
89
|
+
)${systemPromptFile ? ", __aaiSystemPrompt)" : ""};
|
|
90
|
+
export default __aaiAgent;
|
|
91
|
+
export const __aaiConfig = {
|
|
92
|
+
...toAgentConfig(__aaiAgent),
|
|
93
|
+
toolSchemas: agentToolsToSchemas(__aaiAgent.tools ?? {}),
|
|
94
|
+
};
|
|
95
|
+
${runtime ? `export const __aaiCreateRuntime = (opts: Record<string, unknown>) =>
|
|
96
|
+
createRuntime({ ...opts, agent: __aaiAgent });
|
|
97
|
+
` : ""}`;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Bundle agent.ts into a single ESM string for the sandbox worker.
|
|
101
|
+
*
|
|
102
|
+
* Zod is bundled in — zod 4's `Function()` usage is wrapped in try/catch
|
|
103
|
+
* and gracefully degrades in restricted environments like Deno.
|
|
104
|
+
*
|
|
105
|
+
* @internal — build hook for aai-server/the studio; not a supported public
|
|
106
|
+
* API and not covered by semver.
|
|
107
|
+
*/
|
|
108
|
+
async function buildWorker(cwd, opts = {}) {
|
|
109
|
+
const wrapperPath = path.join(cwd, WRAPPER_ENTRY_REL);
|
|
110
|
+
const [, toolFiles, systemPromptFile] = await Promise.all([
|
|
111
|
+
fs.mkdir(path.dirname(wrapperPath), { recursive: true }),
|
|
112
|
+
discoverToolFiles(cwd),
|
|
113
|
+
hasSystemPromptFile(cwd)
|
|
114
|
+
]);
|
|
115
|
+
await fs.writeFile(wrapperPath, wrapperEntrySource(opts.runtime !== false, toolFiles, systemPromptFile), "utf-8");
|
|
116
|
+
const plugins = opts.plugins ?? [];
|
|
117
|
+
let result;
|
|
118
|
+
try {
|
|
119
|
+
result = await withPreservedNodeEnv(() => build({
|
|
120
|
+
root: cwd,
|
|
121
|
+
logLevel: "silent",
|
|
122
|
+
...opts.configFile === false && { configFile: false },
|
|
123
|
+
...plugins.length > 0 && { plugins },
|
|
124
|
+
ssr: { noExternal: true },
|
|
125
|
+
build: {
|
|
126
|
+
ssr: true,
|
|
127
|
+
lib: {
|
|
128
|
+
entry: wrapperPath,
|
|
129
|
+
formats: ["es"],
|
|
130
|
+
fileName: "worker"
|
|
131
|
+
},
|
|
132
|
+
target: "node20",
|
|
133
|
+
minify: opts.minify ? "oxc" : false,
|
|
134
|
+
write: false,
|
|
135
|
+
rollupOptions: { output: {
|
|
136
|
+
entryFileNames: "[name].js",
|
|
137
|
+
codeSplitting: false
|
|
138
|
+
} }
|
|
139
|
+
}
|
|
140
|
+
}));
|
|
141
|
+
} finally {
|
|
142
|
+
await fs.rm(wrapperPath, { force: true }).catch(() => void 0);
|
|
143
|
+
}
|
|
144
|
+
const output = Array.isArray(result) ? result[0] : result;
|
|
145
|
+
invariant(output !== void 0, "bundle.worker.output");
|
|
146
|
+
const chunk = output.output.find((o) => o.type === "chunk" && o.isEntry);
|
|
147
|
+
invariant(chunk !== void 0, "bundle.worker.entry-chunk", () => ({ kinds: output.output.map((o) => o.type) }));
|
|
148
|
+
return chunk.code;
|
|
149
|
+
}
|
|
150
|
+
//#endregion
|
|
3
151
|
export { buildWorker };
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { a as ok, n as fail } from "./_output-CKkmWs7i.mjs";
|
|
2
|
+
import { a as ok, n as fail, t as CliError } from "./_output-CKkmWs7i.mjs";
|
|
3
3
|
import { n as log } from "./_ui-u7T4YooX.mjs";
|
|
4
|
+
import { i as readProjectConfig } from "./_config-CmJOFsAP.mjs";
|
|
4
5
|
import { n as getServerInfo } from "./_agent-BTwZJu4P.mjs";
|
|
5
6
|
import { errorMessage, omitUndefined } from "@alexkroman1/aai/utils";
|
|
6
7
|
import { createWorkflowApiClient } from "@alexkroman1/aai/workflow-api";
|
|
@@ -35,32 +36,105 @@ import { createWorkflowApiClient } from "@alexkroman1/aai/workflow-api";
|
|
|
35
36
|
/** Runs listed when the caller names no limit — a terminal is not a dashboard. */
|
|
36
37
|
const DEFAULT_RUN_LIMIT = 20;
|
|
37
38
|
/**
|
|
39
|
+
* The base URL {@link createWorkflowApiClient} is given for `--agent`.
|
|
40
|
+
*
|
|
41
|
+
* A dev server has no slug: `createServer` mounts the workflow API at
|
|
42
|
+
* `WORKFLOW_API_PREFIX` on the ORIGIN, where the platform serves it under
|
|
43
|
+
* `/:slug`. So targeting one is not a matter of a different origin — it is the
|
|
44
|
+
* absence of the path segment, which nothing in this command could express.
|
|
45
|
+
*
|
|
46
|
+
* Validated rather than interpolated. This value is joined into a request path,
|
|
47
|
+
* and the CLI's standing rule is that anything reaching a URL is checked at the
|
|
48
|
+
* one point it becomes a target (`resolveDeployTarget`'s slug guard is the same
|
|
49
|
+
* rule). It is also the only origin here that `resolveServerUrl` does not
|
|
50
|
+
* produce, so stripping the trailing slash is this function's job — the join
|
|
51
|
+
* site deliberately carries no copy of that.
|
|
52
|
+
*
|
|
53
|
+
* No trust check, deliberately, and the asymmetry with `--server` is the point:
|
|
54
|
+
* `--server` pairs an origin with the user's PLATFORM API KEY, which is why a
|
|
55
|
+
* repo-supplied loopback URL is refused there. Nothing credentialed goes to
|
|
56
|
+
* `--agent` — the workflow API takes the agent's own bearer or none — so there
|
|
57
|
+
* is nothing for a hostile origin to collect, and requiring `aai login` to read
|
|
58
|
+
* runs off a server on your own laptop is the defect this flag exists for.
|
|
59
|
+
*/
|
|
60
|
+
function agentBaseUrl(raw) {
|
|
61
|
+
const url = URL.parse(raw);
|
|
62
|
+
if (url === null) throw new CliError("bad_agent_url", `--agent is not a URL: ${JSON.stringify(raw)}`, HINT_URL);
|
|
63
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") throw new CliError("bad_agent_url", `--agent must be http or https, not ${url.protocol.replace(":", "")}`, HINT_URL);
|
|
64
|
+
return raw.replace(/\/+$/, "");
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
38
67
|
* A client for the agent's workflow API, plus the bearer when one was given.
|
|
39
68
|
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
69
|
+
* Two ways in. `--agent <url>` names a server the caller is running themselves
|
|
70
|
+
* — `aai dev`, or the scaffold's `npm start` — and takes that URL as the base:
|
|
71
|
+
* no project config, no published slug, and no `ensureApiKey`, none of which
|
|
72
|
+
* such a server has or wants. Otherwise `getServerInfo` turns "this directory"
|
|
73
|
+
* into a platform origin and a published slug.
|
|
74
|
+
*
|
|
75
|
+
* The client is handed the AGENT's base URL and appends the route prefix itself,
|
|
76
|
+
* so the `/workflows` literal is not spelled here — it is the same constant the
|
|
77
|
+
* server matches on.
|
|
45
78
|
*
|
|
46
79
|
* `serverUrl` is joined as-is: `resolveServerUrl` is the single producer of
|
|
47
|
-
* every origin that reaches
|
|
80
|
+
* every origin that reaches THAT branch and strips trailing slashes once, at
|
|
48
81
|
* resolution time, precisely so join sites do not each carry a copy. The copy
|
|
49
82
|
* this used to hold also DISAGREED with it — `/\/$/` takes one slash where the
|
|
50
83
|
* upstream `/\/+$/` takes all — so the two would have differed on the only
|
|
51
84
|
* input either was written for.
|
|
85
|
+
*
|
|
86
|
+
* **The no-deployment failure is raised HERE rather than by
|
|
87
|
+
* `requireDeployedSlug`, and the ordering is why.** `getServerInfo` resolves the
|
|
88
|
+
* API key BEFORE it looks for a slug, so in an undeployed project the first
|
|
89
|
+
* thing a developer running `aai dev` saw was `not_logged_in` — pointing at
|
|
90
|
+
* `aai login` for a command that never sends the key it was asking for, with
|
|
91
|
+
* the real cause two errors away. Reading the project config first puts the
|
|
92
|
+
* cause first and lets the sentence name every way out: publish it, or point at
|
|
93
|
+
* the server already running.
|
|
52
94
|
*/
|
|
53
95
|
async function target(cwd, opts) {
|
|
96
|
+
if (opts.agent !== void 0) {
|
|
97
|
+
const baseUrl = agentBaseUrl(opts.agent);
|
|
98
|
+
return {
|
|
99
|
+
api: createWorkflowApiClient({
|
|
100
|
+
baseUrl,
|
|
101
|
+
...omitUndefined({ token: opts.token })
|
|
102
|
+
}),
|
|
103
|
+
name: baseUrl
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (!(await readProjectConfig(cwd))?.slug) throw new CliError("no_deployment", NO_DEPLOYMENT, HINT_AGENT);
|
|
54
107
|
const { serverUrl, slug } = await getServerInfo(cwd, opts.server);
|
|
55
108
|
return {
|
|
56
109
|
api: createWorkflowApiClient({
|
|
57
110
|
baseUrl: `${serverUrl}/${slug}`,
|
|
58
111
|
...omitUndefined({ token: opts.token })
|
|
59
112
|
}),
|
|
60
|
-
slug
|
|
113
|
+
name: slug
|
|
61
114
|
};
|
|
62
115
|
}
|
|
63
116
|
/**
|
|
117
|
+
* What a project with no deployment is told, and why it names two ways out.
|
|
118
|
+
*
|
|
119
|
+
* `requireDeployedSlug`'s sentence — "run `aai publish` first" — is right for
|
|
120
|
+
* `aai secret` and `aai delete`, which have nothing to talk to until the agent
|
|
121
|
+
* is on the platform. It is wrong here often enough to be a defect: a workflow
|
|
122
|
+
* API is the one agent surface that is fully live under `aai dev`, so the
|
|
123
|
+
* developer being told to publish frequently has the thing they asked about
|
|
124
|
+
* answering on localhost already.
|
|
125
|
+
*/
|
|
126
|
+
const NO_DEPLOYMENT = "This project has no deployed agent, and no --agent URL was given — so there is nothing for `aai workflow` to ask.";
|
|
127
|
+
/**
|
|
128
|
+
* What a malformed `--agent` is told.
|
|
129
|
+
*
|
|
130
|
+
* Its own hint rather than {@link HINT_AGENT}: the caller already knows about
|
|
131
|
+
* the flag — they typed it — so repeating "run `aai publish`" answers a
|
|
132
|
+
* question they did not ask.
|
|
133
|
+
*/
|
|
134
|
+
const HINT_URL = "Pass a full origin, e.g. --agent http://localhost:3000";
|
|
135
|
+
/** How to reach a server you are running yourself. Paired with {@link NO_DEPLOYMENT}. */
|
|
136
|
+
const HINT_AGENT = "Run `aai publish` to deploy it, or pass --agent <url> to target a server you are already running — `--agent http://localhost:3000` for `aai dev`, which needs no login and no slug.";
|
|
137
|
+
/**
|
|
64
138
|
* Run one call, turning a rejection into this command's failure result.
|
|
65
139
|
*
|
|
66
140
|
* The client throws with the AGENT'S own sentence — an unknown workflow names
|
|
@@ -93,11 +167,11 @@ async function attempt(code, call) {
|
|
|
93
167
|
}
|
|
94
168
|
/** `aai workflow list` — what this agent declares. */
|
|
95
169
|
async function executeWorkflowList(cwd, opts) {
|
|
96
|
-
const { api,
|
|
170
|
+
const { api, name } = await target(cwd, opts);
|
|
97
171
|
const res = await attempt("workflow_list_failed", () => api.list());
|
|
98
172
|
if (!res.ok) return res;
|
|
99
173
|
const workflows = res.value;
|
|
100
|
-
if (workflows.length === 0) log.info(`${
|
|
174
|
+
if (workflows.length === 0) log.info(`${name} declares no workflows`);
|
|
101
175
|
else for (const w of workflows) log.info(`${w.name}${w.description ? ` — ${w.description}` : ""}`);
|
|
102
176
|
return ok({ workflows });
|
|
103
177
|
}
|
package/dist/workflow.d.ts
CHANGED
|
@@ -39,6 +39,7 @@ type Run = WorkflowRunSnapshot;
|
|
|
39
39
|
type WorkflowOptions = {
|
|
40
40
|
server?: string | undefined;
|
|
41
41
|
token?: string | undefined;
|
|
42
|
+
agent?: string | undefined;
|
|
42
43
|
};
|
|
43
44
|
/** `aai workflow list` — what this agent declares. */
|
|
44
45
|
export declare function executeWorkflowList(cwd: string, opts: WorkflowOptions): Promise<CommandResult<{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alexkroman1/aai-cli",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "10.0.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"aai": "bin.mjs"
|
|
@@ -33,7 +33,6 @@
|
|
|
33
33
|
],
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@clack/prompts": "^1.7.0",
|
|
36
|
-
"@workflow/builders": "4.1.7",
|
|
37
36
|
"chokidar": "^5.0.0",
|
|
38
37
|
"citty": "^0.2.2",
|
|
39
38
|
"env-paths": "^4.0.0",
|
|
@@ -44,16 +43,15 @@
|
|
|
44
43
|
"p-timeout": "^7.0.1",
|
|
45
44
|
"vite": "^8.2.1",
|
|
46
45
|
"zod": "^4.4.3",
|
|
47
|
-
"@alexkroman1/aai": "
|
|
48
|
-
"@alexkroman1/aai-runtime": "
|
|
49
|
-
"@alexkroman1/aai-ui": "
|
|
46
|
+
"@alexkroman1/aai": "10.0.1",
|
|
47
|
+
"@alexkroman1/aai-runtime": "10.0.1",
|
|
48
|
+
"@alexkroman1/aai-ui": "10.0.1"
|
|
50
49
|
},
|
|
51
50
|
"devDependencies": {
|
|
52
51
|
"playwright": "^1.62.1",
|
|
53
52
|
"tsdown": "^0.22.14",
|
|
54
53
|
"verdaccio": "^6.9.2",
|
|
55
54
|
"vitest": "^4.1.10",
|
|
56
|
-
"workflow": "4.8.2",
|
|
57
55
|
"aai-templates": "0.3.8"
|
|
58
56
|
},
|
|
59
57
|
"peerDependencies": {
|