@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.
Files changed (81) hide show
  1. package/dist/{_bundler-CDuYl5Gb.mjs → _bundler-B31MqYaD.mjs} +7 -33
  2. package/dist/_bundler.d.ts +3 -41
  3. package/dist/{_dev-server-BAR8Ya43.mjs → _dev-server-Bo5dSBzQ.mjs} +140 -44
  4. package/dist/_dev-server.d.ts +1 -8
  5. package/dist/_dev-vite-config.d.ts +21 -0
  6. package/dist/{_init-BNotRgV1.mjs → _init-BURbdHNM.mjs} +1 -1
  7. package/dist/{_slug-api--ZBmIGH-.mjs → _slug-api-CfGQq1Tt.mjs} +1 -1
  8. package/dist/{_templates-4WcKOjS5.mjs → _templates-0Zr2z6yA.mjs} +37 -2
  9. package/dist/_templates.d.ts +29 -0
  10. package/dist/{build-DvDdSwAS.mjs → build-DKC2e9o_.mjs} +1 -2
  11. package/dist/cli.mjs +46 -22
  12. package/dist/{delete-8pjH8n8n.mjs → delete-X_CRgCf6.mjs} +1 -1
  13. package/dist/{deploy-CjU3e-3P.mjs → deploy-CchW5pmw.mjs} +2 -2
  14. package/dist/{dev-dFzdvwa7.mjs → dev-BfVl1uBA.mjs} +1 -1
  15. package/dist/{init-DK7GpIF9.mjs → init-BRfoc2EK.mjs} +2 -2
  16. package/dist/{logs-D2Yhqe2w.mjs → logs-hWnJ66Bl.mjs} +2 -2
  17. package/dist/scaffold/CLAUDE.md +162 -54
  18. package/dist/scaffold/package.json +4 -6
  19. package/dist/scaffold/server.mjs +12 -23
  20. package/dist/{secret-CwgDLpSy.mjs → secret-B-3T5tvr.mjs} +2 -2
  21. package/dist/{studio-D3-HWIfk.mjs → studio-wVWNLREn.mjs} +4 -3
  22. package/dist/templates/call-audit/agent.eval.test.ts +1 -1
  23. package/dist/templates/call-audit/agent.test.ts +80 -8
  24. package/dist/templates/call-audit/client.tsx +87 -3
  25. package/dist/templates/call-audit/workflows/audit.ts +52 -32
  26. package/dist/templates/call-audit/workflows/ingest.ts +15 -15
  27. package/dist/templates/call-audit/workflows/media.ts +1 -1
  28. package/dist/templates/call-audit/workflows/summarize.ts +0 -4
  29. package/dist/templates/link-digest/agent.eval.test.ts +4 -2
  30. package/dist/templates/link-digest/agent.test.ts +25 -5
  31. package/dist/templates/link-digest/client.tsx +65 -7
  32. package/dist/templates/link-digest/workflows/digest.ts +39 -29
  33. package/dist/templates/podcast-digest/agent.eval.test.ts +5 -5
  34. package/dist/templates/podcast-digest/agent.test.ts +108 -2
  35. package/dist/templates/podcast-digest/client.tsx +89 -8
  36. package/dist/templates/podcast-digest/workflows/digest.ts +67 -48
  37. package/dist/templates/podcast-digest/workflows/feeds.ts +1 -4
  38. package/dist/templates/podcast-digest/workflows/slack.ts +5 -6
  39. package/dist/templates/recap-workflow/agent.eval.test.ts +72 -8
  40. package/dist/templates/recap-workflow/agent.test.ts +323 -83
  41. package/dist/templates/recap-workflow/shared.ts +4 -3
  42. package/dist/templates/recap-workflow/workflows/recap.ts +322 -86
  43. package/dist/templates/recap-workflow/workflows/tokens.ts +21 -0
  44. package/dist/templates/redline/agent.eval.test.ts +1 -1
  45. package/dist/templates/redline/agent.test.ts +1 -1
  46. package/dist/templates/redline/client.tsx +83 -3
  47. package/dist/templates/redline/workflows/redline.ts +26 -23
  48. package/dist/templates/research-workflow/agent.eval.test.ts +9 -8
  49. package/dist/templates/research-workflow/agent.test.ts +32 -6
  50. package/dist/templates/research-workflow/agent.ts +4 -4
  51. package/dist/templates/research-workflow/shared.ts +4 -3
  52. package/dist/templates/research-workflow/workflows/prompts.ts +1 -1
  53. package/dist/templates/research-workflow/workflows/research.ts +35 -38
  54. package/dist/templates/spoken-summary/agent.eval.test.ts +3 -3
  55. package/dist/templates/spoken-summary/agent.test.ts +10 -8
  56. package/dist/templates/spoken-summary/client.tsx +72 -3
  57. package/dist/templates/spoken-summary/workflows/summarize.ts +25 -21
  58. package/dist/templates/spoken-summary/workflows/transcribe.ts +15 -18
  59. package/dist/templates/transcription-workflow/agent.eval.test.ts +1 -1
  60. package/dist/templates/transcription-workflow/agent.test.ts +626 -14
  61. package/dist/templates/transcription-workflow/agent.ts +6 -4
  62. package/dist/templates/transcription-workflow/client.tsx +106 -308
  63. package/dist/templates/transcription-workflow/recover.test.ts +141 -0
  64. package/dist/templates/transcription-workflow/recover.ts +130 -0
  65. package/dist/templates/transcription-workflow/run-panel.tsx +203 -0
  66. package/dist/templates/transcription-workflow/total-latency.tsx +144 -0
  67. package/dist/templates/transcription-workflow/workflows/batch.ts +21 -24
  68. package/dist/templates/transcription-workflow/workflows/downsample.ts +273 -0
  69. package/dist/templates/transcription-workflow/workflows/normalize.ts +17 -40
  70. package/dist/templates/transcription-workflow/workflows/stream.ts +177 -24
  71. package/dist/templates/transcription-workflow/workflows/transcribe.ts +69 -32
  72. package/dist/templates/transcription-workflow/workflows/wav.ts +15 -4
  73. package/dist/worker-bundler.d.ts +0 -14
  74. package/dist/worker-bundler.mjs +149 -1
  75. package/dist/{workflow-BflATMmU.mjs → workflow-BBu5oEDw.mjs} +84 -10
  76. package/dist/workflow.d.ts +1 -0
  77. package/package.json +4 -6
  78. package/dist/_workflow-scan.d.ts +0 -108
  79. package/dist/worker-bundler-CCVEDjm1.mjs +0 -650
  80. package/dist/workflow-bundler.d.ts +0 -138
  81. 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 world's call, not this number's.**
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 the workflow world's worker concurrency, which on the
174
- * `DATABASE_URL` path defaults to three so on a default deployment a width
175
- * above three is inert while still costing a queued job per item. That makes
176
- * this the FAR SIDE's knee and the width to use once an operator has raised
177
- * the ceiling, not a promise about a stock deployment. See "The WINDOW is not
178
- * the concurrency" in `@alexkroman1/aai/step`'s `mapConcurrent`; the numbers
179
- * above were measured against the endpoint and say nothing about that layer.
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
- const perSegment = bytesPerSecond(format) * (SEGMENT_SECONDS + SEGMENT_OVERLAP_SECONDS);
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
- const [startedAt, ready] = await Promise.all([startClock(), normalizeRecording(input.recording)]);
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(ready.recording, plan.format, segment),
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(input.recording, plan.durationMs, parts, startedAt);
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
- "use step";
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, head.info.size));
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(audio.bytes, format),
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
- * No directive in this file, and that is legal — the Workflow DevKit's builder
7
- * scans `workflows/` and transforms only what carries a `"use workflow"` /
8
- * `"use step"` body, so an ordinary module can sit beside them. It is a separate
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
- /** What the sync endpoint will accept in one request. */
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
  /**
@@ -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.
@@ -1,3 +1,151 @@
1
1
  #!/usr/bin/env node
2
- import { t as buildWorker } from "./worker-bundler-CCVEDjm1.mjs";
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
- * `getServerInfo` is what turns "this directory" into an origin and a published
41
- * slug, and it already refuses a project that has never been deployed with the
42
- * sentence naming `aai publish`. The client is handed the AGENT's base URL and
43
- * appends the route prefix itself, so the `/workflows` literal is not spelled
44
- * here it is the same constant the server matches on.
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 here and strips trailing slashes once, at
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, slug } = await target(cwd, opts);
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(`${slug} declares no workflows`);
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
  }
@@ -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": "9.2.0",
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": "9.2.0",
48
- "@alexkroman1/aai-runtime": "9.2.0",
49
- "@alexkroman1/aai-ui": "9.2.0"
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": {