@alexkroman1/aai-cli 13.3.0 → 14.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.
Files changed (134) hide show
  1. package/dist/{_agent-Clbq3a4h.mjs → _agent-BzUeqOdj.mjs} +32 -9
  2. package/dist/_agent.d.ts +23 -0
  3. package/dist/{_bundler-DEkjO1d8.mjs → _bundler-DolUCMxu.mjs} +8 -7
  4. package/dist/_cli-common.d.ts +45 -6
  5. package/dist/{_config-ZY2euhdh.mjs → _config-DYzC6WMD.mjs} +2 -2
  6. package/dist/_dev-env.d.ts +1 -1
  7. package/dist/{_dev-server-C13xuGB1.mjs → _dev-server-CSMqF8PN.mjs} +20 -22
  8. package/dist/_dev-vite-config.d.ts +3 -3
  9. package/dist/{_init-Ci4QGjp8.mjs → _init-CQ8idAwo.mjs} +9 -23
  10. package/dist/{_output-DbJt9gsl.mjs → _output-DBP9Op_d.mjs} +22 -13
  11. package/dist/_output.d.ts +10 -9
  12. package/dist/{_preflight-B2laWPdU.mjs → _preflight-CcAR577V.mjs} +2 -2
  13. package/dist/_resource-commands.d.ts +9 -9
  14. package/dist/{_server-common-BV4_CrP3.mjs → _server-common-vILJp3it.mjs} +1 -1
  15. package/dist/{_slug-api-DlKpj91X.mjs → _slug-api-CGvc2YSQ.mjs} +18 -9
  16. package/dist/_slug-api.d.ts +19 -0
  17. package/dist/{_studio-DTi3EdXQ.mjs → _studio-DnR_BqFp.mjs} +7 -3
  18. package/dist/_studio-commands.d.ts +13 -13
  19. package/dist/{_templates-V4l5sZJy.mjs → _templates-CK4oKoeX.mjs} +3 -3
  20. package/dist/{_typecheck-gate-sV4N5F93.mjs → _typecheck-gate-U3jcil8L.mjs} +3 -2
  21. package/dist/_typecheck-gate.d.ts +3 -1
  22. package/dist/{_utils-Dhd058D9.mjs → _utils-D5JGcjiW.mjs} +43 -1
  23. package/dist/_utils.d.ts +35 -0
  24. package/dist/{_workflow-determinism-C9l8uC2R.mjs → _workflow-determinism-CvpqMpdh.mjs} +18 -26
  25. package/dist/_workflow-determinism.d.ts +3 -3
  26. package/dist/{build-CWyFGe-W.mjs → build-Mxk8gWvX.mjs} +46 -7
  27. package/dist/build.d.ts +5 -0
  28. package/dist/cli.d.ts +1 -2
  29. package/dist/cli.mjs +117 -135
  30. package/dist/{client-bundler-CT-_26c5.mjs → client-bundler-BJgREAh6.mjs} +1 -1
  31. package/dist/client-bundler.mjs +1 -1
  32. package/dist/{delete-RqlQZ6Y_.mjs → delete-BdFnbnVR.mjs} +4 -4
  33. package/dist/{deploy-B7f3CK7v.mjs → deploy-uAJ4NukN.mjs} +11 -11
  34. package/dist/{dev-DamXieW_.mjs → dev-DApPSaE_.mjs} +3 -3
  35. package/dist/{eval-h3DTVfRw.mjs → eval-BK47A_K5.mjs} +3 -3
  36. package/dist/{init-D-vFrV8h.mjs → init-DukDxECd.mjs} +11 -19
  37. package/dist/{login-CDiemNmC.mjs → login-DaLrPOFg.mjs} +4 -6
  38. package/dist/{logs-DGwPNCzB.mjs → logs-7BRwq0cG.mjs} +21 -14
  39. package/dist/project-config.mjs +1 -1
  40. package/dist/scaffold/CLAUDE.md +84 -56
  41. package/dist/scaffold/package.json +4 -4
  42. package/dist/scaffold/server.mjs +129 -25
  43. package/dist/{secret-V1w7y9-j.mjs → secret-Cjp912yF.mjs} +5 -5
  44. package/dist/secret.d.ts +8 -6
  45. package/dist/{studio-BpMCfUNx.mjs → studio-CpHlNHUZ.mjs} +13 -15
  46. package/dist/templates/call-audit/agent.test.ts +11 -6
  47. package/dist/templates/call-audit/agent.ts +2 -2
  48. package/dist/templates/call-audit/client.tsx +27 -42
  49. package/dist/templates/call-audit/workflows/audit.ts +7 -5
  50. package/dist/templates/call-audit/workflows/ingest.ts +7 -7
  51. package/dist/templates/call-audit/workflows/media.ts +12 -37
  52. package/dist/templates/call-audit/workflows/summarize.ts +8 -8
  53. package/dist/templates/call-audit/workflows/sync-api.ts +3 -3
  54. package/dist/templates/dispatch-center/agent.test.ts +7 -7
  55. package/dist/templates/dispatch-center/client.tsx +53 -61
  56. package/dist/templates/dispatch-center/shared.ts +0 -8
  57. package/dist/templates/dispatch-center/tools/ops_dashboard.ts +4 -1
  58. package/dist/templates/embedded-assets/agent.eval.test.ts +4 -2
  59. package/dist/templates/health-assistant/agent.eval.test.ts +4 -2
  60. package/dist/templates/infocom-adventure/client.tsx +40 -36
  61. package/dist/templates/link-digest/agent.test.ts +3 -3
  62. package/dist/templates/link-digest/agent.ts +2 -2
  63. package/dist/templates/link-digest/client.tsx +27 -17
  64. package/dist/templates/link-digest/workflows/digest.ts +13 -16
  65. package/dist/templates/night-owl/agent.eval.test.ts +4 -2
  66. package/dist/templates/night-owl/client.tsx +8 -2
  67. package/dist/templates/pipeline-simple/agent.test.ts +2 -2
  68. package/dist/templates/pizza-ordering/client.tsx +5 -4
  69. package/dist/templates/pizza-ordering/shared.ts +3 -8
  70. package/dist/templates/pizza-ordering/tools/add_pizza.ts +3 -2
  71. package/dist/templates/pizza-ordering/tools/place_order.ts +3 -2
  72. package/dist/templates/pizza-ordering/tools/remove_pizza.ts +3 -2
  73. package/dist/templates/pizza-ordering/tools/update_pizza.ts +3 -2
  74. package/dist/templates/pizza-ordering/tools/view_order.ts +3 -2
  75. package/dist/templates/plan-and-execute/agent.test.ts +10 -5
  76. package/dist/templates/plan-and-execute/client.tsx +2 -2
  77. package/dist/templates/podcast-digest/agent.test.ts +20 -11
  78. package/dist/templates/podcast-digest/client.tsx +16 -22
  79. package/dist/templates/podcast-digest/workflows/digest.ts +21 -19
  80. package/dist/templates/podcast-digest/workflows/feeds.ts +61 -33
  81. package/dist/templates/podcast-digest/workflows/slack.ts +7 -7
  82. package/dist/templates/recap-workflow/agent.eval.test.ts +21 -13
  83. package/dist/templates/recap-workflow/agent.test.ts +27 -23
  84. package/dist/templates/recap-workflow/workflows/recap.ts +24 -28
  85. package/dist/templates/redline/agent.eval.test.ts +5 -8
  86. package/dist/templates/redline/agent.test.ts +1 -1
  87. package/dist/templates/redline/client.tsx +36 -34
  88. package/dist/templates/redline/workflows/redline.ts +17 -17
  89. package/dist/templates/research-workflow/agent.eval.test.ts +5 -5
  90. package/dist/templates/research-workflow/agent.test.ts +10 -7
  91. package/dist/templates/research-workflow/workflows/research.ts +28 -30
  92. package/dist/templates/retail/agent.test.ts +17 -15
  93. package/dist/templates/retail/cancel.ts +7 -6
  94. package/dist/templates/retail/client.tsx +39 -58
  95. package/dist/templates/retail/payment.ts +4 -3
  96. package/dist/templates/retail/pending.test.ts +19 -19
  97. package/dist/templates/retail/store.test.ts +5 -5
  98. package/dist/templates/retail/swap.ts +8 -11
  99. package/dist/templates/solo-rpg/agent.eval.test.ts +2 -16
  100. package/dist/templates/solo-rpg/agent.test.ts +12 -12
  101. package/dist/templates/solo-rpg/client.tsx +3 -3
  102. package/dist/templates/spoken-summary/agent.test.ts +9 -9
  103. package/dist/templates/spoken-summary/agent.ts +3 -3
  104. package/dist/templates/spoken-summary/client.tsx +23 -30
  105. package/dist/templates/spoken-summary/workflows/summarize.ts +11 -11
  106. package/dist/templates/spoken-summary/workflows/transcribe.ts +14 -14
  107. package/dist/templates/support-line/client.tsx +4 -3
  108. package/dist/templates/support-line/prompts.ts +0 -2
  109. package/dist/templates/transcription-workflow/agent.test.ts +20 -12
  110. package/dist/templates/transcription-workflow/agent.ts +2 -2
  111. package/dist/templates/transcription-workflow/client.tsx +15 -14
  112. package/dist/templates/transcription-workflow/recover.ts +1 -1
  113. package/dist/templates/transcription-workflow/run-panel.tsx +34 -14
  114. package/dist/templates/transcription-workflow/total-latency.tsx +46 -20
  115. package/dist/templates/transcription-workflow/workflows/batch.ts +15 -15
  116. package/dist/templates/transcription-workflow/workflows/downsample.ts +1 -1
  117. package/dist/templates/transcription-workflow/workflows/normalize.ts +15 -19
  118. package/dist/templates/transcription-workflow/workflows/stream.ts +12 -12
  119. package/dist/templates/transcription-workflow/workflows/sync-api.ts +3 -3
  120. package/dist/templates/transcription-workflow/workflows/transcribe.ts +23 -34
  121. package/dist/templates/travel-concierge/agent.eval.test.ts +2 -2
  122. package/dist/templates/travel-concierge/agent.test.ts +15 -7
  123. package/dist/templates/travel-concierge/client.tsx +2 -2
  124. package/dist/templates/travel-concierge/shared.ts +10 -14
  125. package/dist/templates/travel-concierge/tools/lookup_booking.ts +4 -3
  126. package/dist/templates/travel-concierge/tools/search_car_rentals.ts +3 -2
  127. package/dist/templates/travel-concierge/tools/search_excursions.ts +3 -2
  128. package/dist/templates/travel-concierge/tools/search_flights.ts +3 -2
  129. package/dist/templates/travel-concierge/tools/search_hotels.ts +3 -2
  130. package/dist/{test-guenJUxi.mjs → test-D032V2cA.mjs} +4 -17
  131. package/dist/typecheck.mjs +1 -1
  132. package/dist/worker-bundler.mjs +1 -1
  133. package/dist/{workflow-BZyKLkhV.mjs → workflow-CVioyO3V.mjs} +6 -5
  134. package/package.json +4 -4
@@ -16,7 +16,7 @@
16
16
  * ## The argv is ours, so it is BUILT rather than embedded
17
17
  *
18
18
  * `runFfmpeg` passes `args` through verbatim — no `-y`, no `-loglevel` — so the
19
- * standing flags are a decision this file makes once ({@link standardFlags}) and
19
+ * standing flags come from `ffmpegBaseArgs` on `@alexkroman1/aai/ffmpeg`, and
20
20
  * every invocation's real argv is a value a test can assert on. It is also why
21
21
  * the filter strings below carry no shell quoting: each is ONE element of an
22
22
  * argv array, so the commas that chain filters and the colons that separate their
@@ -58,7 +58,9 @@
58
58
  * longer than the cap gets the blind cut, and says so.
59
59
  */
60
60
 
61
+ import { ffmpegBaseArgs } from "@alexkroman1/aai/ffmpeg";
61
62
  import type { PcmFormat } from "@alexkroman1/aai/step";
63
+ import { safeJsonParse } from "@alexkroman1/aai/utils";
62
64
  import { z } from "zod";
63
65
 
64
66
  /**
@@ -151,20 +153,6 @@ export const MAX_SEGMENT_SECONDS = 110;
151
153
  */
152
154
  export const MIN_SEGMENT_SECONDS = 1;
153
155
 
154
- /** The standing flags, on every invocation this desk makes. */
155
- export function standardFlags(): string[] {
156
- return [
157
- "-hide_banner",
158
- // Progress lines are noise in a captured stderr, and the SDK keeps only a
159
- // tail of it — so suppressing them is what leaves room for the diagnosis.
160
- "-nostats",
161
- // In a guest there is no terminal, and an ffmpeg that decides to read stdin
162
- // is a process that never exits.
163
- "-nostdin",
164
- "-y",
165
- ];
166
- }
167
-
168
156
  /** A loudness measurement, as `loudnorm`'s first pass reports it. */
169
157
  export type Loudness = {
170
158
  /** Integrated loudness, LUFS. */
@@ -233,9 +221,9 @@ export class MediaAnalysisError extends Error {
233
221
  */
234
222
  export function measureLoudnessArgs(input: string): string[] {
235
223
  return [
236
- ...standardFlags(),
237
- "-loglevel",
238
- "info",
224
+ // `info`, not the default `error`: `print_format=json` reports through the
225
+ // LOG, so at `error` this pass runs, succeeds, and prints nothing.
226
+ ...ffmpegBaseArgs({ loglevel: "info" }),
239
227
  "-i",
240
228
  input,
241
229
  "-af",
@@ -273,7 +261,7 @@ export function parseLoudness(stderr: string): Loudness {
273
261
  // different remedies, and the second message quotes the value ffmpeg actually
274
262
  // printed — which needs the block still in hand, so the gate cannot be folded
275
263
  // into the schema below.
276
- const block = LoudnessBlock.safeParse(safeJson(stderr.slice(open, close + 1)));
264
+ const block = LoudnessBlock.safeParse(safeJsonParse(stderr.slice(open, close + 1)));
277
265
  if (!block.success) {
278
266
  throw new MediaAnalysisError("The loudness pass printed a block that is not JSON.");
279
267
  }
@@ -301,7 +289,7 @@ export function parseLoudness(stderr: string): Loudness {
301
289
  /**
302
290
  * Is the found `{…}` an object at all?
303
291
  *
304
- * The reachable failure is {@link safeJson} answering `undefined` — a brace pair
292
+ * The reachable failure is {@link safeJsonParse} answering `undefined` — a brace pair
305
293
  * found in ffmpeg's chatter with something other than JSON between them — and
306
294
  * that is a different sentence from a value being unreadable, which is why this
307
295
  * gate exists at all rather than being folded into {@link LoudnessValues}.
@@ -370,12 +358,10 @@ export function normalizeArgs(
370
358
  ].join(":");
371
359
 
372
360
  return [
373
- ...standardFlags(),
374
361
  // Quiet, and the analysis still arrives: `ametadata` writes its file
375
362
  // directly rather than through the log, which is the property that lets this
376
363
  // pass be both silent and complete.
377
- "-loglevel",
378
- "error",
364
+ ...ffmpegBaseArgs(),
379
365
  "-i",
380
366
  input,
381
367
  "-af",
@@ -456,7 +442,7 @@ export function parseSilences(log: string, durationSec: number): Silence[] {
456
442
  * Exact rather than rounded, and that distinction cost a bug: `pcmDurationMs`
457
443
  * answers whole MILLISECONDS, so a 640,500-byte file reports 20,016 ms where it
458
444
  * really holds 20,015.625. Planning from the rounded number put the last segment's
459
- * `endByte` at 640,512 — twelve bytes past the end of the file. `readUpload` clamps
445
+ * `endByte` at 640,512 — twelve bytes past the end of the file. `stepReadUpload` clamps
460
446
  * a window to the stored size, so nothing threw; the plan was simply describing
461
447
  * audio that does not exist. Verified against a real ffmpeg, which is the only
462
448
  * place a 12-byte error was ever going to show up.
@@ -474,7 +460,7 @@ export function durationSeconds(totalBytes: number): number {
474
460
  * Greedy from the front: a segment grows until the next cut candidate would take
475
461
  * it past {@link MAX_SEGMENT_SECONDS}, so it ends at the LAST pause that still
476
462
  * fits. Segments are therefore contiguous and non-overlapping — together they are
477
- * the whole recording, each one addressable as a single `readUpload` window.
463
+ * the whole recording, each one addressable as a single `stepReadUpload` window.
478
464
  *
479
465
  * Three properties, each of which a simpler version gets wrong:
480
466
  *
@@ -600,9 +586,7 @@ export function planSegments(silences: readonly Silence[], totalBytes: number):
600
586
  */
601
587
  export function masterArgs(input: string, output: string): string[] {
602
588
  return [
603
- ...standardFlags(),
604
- "-loglevel",
605
- "error",
589
+ ...ffmpegBaseArgs(),
606
590
  "-i",
607
591
  input,
608
592
  "-af",
@@ -652,12 +636,3 @@ function value(line: string, key: string): number | undefined {
652
636
  const parsed = Number(text);
653
637
  return Number.isFinite(parsed) ? parsed : undefined;
654
638
  }
655
-
656
- /** `JSON.parse` that answers `undefined` rather than throwing, so the caller frames the error. */
657
- function safeJson(text: string): unknown {
658
- try {
659
- return JSON.parse(text);
660
- } catch {
661
- return undefined;
662
- }
663
- }
@@ -11,7 +11,7 @@
11
11
  * `spoken-summary` owns the audio ROUND TRIP and is the template to read for it:
12
12
  * why `stepSpeak` exists at all (a `TtsSession` is an event stream wired into a
13
13
  * live pipeline's playback, and a step has no turn to be part of and has to return
14
- * a VALUE), why `writeUpload` is its other half, and why speaking and storing must
14
+ * a VALUE), why `stepWriteUpload` is its other half, and why speaking and storing must
15
15
  * be one step. None of that is restated here.
16
16
  *
17
17
  * **What this file adds is the pass AFTER the synthesis**, and it is the second
@@ -35,8 +35,8 @@
35
35
  import { stat, writeFile } from "node:fs/promises";
36
36
  import { join } from "node:path";
37
37
  import { runFfmpeg } from "@alexkroman1/aai/ffmpeg";
38
- import { report, stepSpeak } from "@alexkroman1/aai/step";
39
- import { stepGenerateJsonClassified, throwFfmpegStepError } from "@alexkroman1/aai/step-errors";
38
+ import { stepReport, stepSpeak } from "@alexkroman1/aai/step";
39
+ import { stepGenerateJsonOrFail, throwFfmpegStepError } from "@alexkroman1/aai/step-errors";
40
40
  import { withTempDir, writeUploadFromFile } from "@alexkroman1/aai/step-files";
41
41
  import { formatBytes, formatDuration, omitUndefined, plural } from "@alexkroman1/aai/utils";
42
42
  import { z } from "zod";
@@ -105,8 +105,8 @@ export async function summarize(
105
105
  source: string,
106
106
  durationMs: number,
107
107
  ): Promise<CallSummary> {
108
- await report("Reading the transcript.");
109
- const reply = await stepGenerateJsonClassified(
108
+ await stepReport("Reading the transcript.");
109
+ const reply = await stepGenerateJsonOrFail(
110
110
  `Audit this transcript of a recorded call (${source}, ${formatDuration(durationMs)}).\n\n` +
111
111
  "Answer with JSON only, in this shape:\n" +
112
112
  `{"headline": "...", "risks": ["..."], "actions": ["..."], "spoken": "..."}\n\n` +
@@ -125,12 +125,12 @@ export async function summarize(
125
125
  system: "You audit recorded calls. You answer with JSON and nothing else.",
126
126
  schema: AuditReply,
127
127
  },
128
- // The `Classified` caller is `stepGenerateJson` plus `throwStepError`, which is
128
+ // The `OrFail` caller is `stepGenerateJson` plus `throwStepError`, which is
129
129
  // what reads the gateway's own status: a 429 is worth another attempt and a 400
130
130
  // is not, and that is what tells the DevKit which.
131
131
  );
132
132
 
133
- await report(
133
+ await stepReport(
134
134
  `Found ${reply.risks.length} ${plural(reply.risks.length, "risk")} and ` +
135
135
  `${reply.actions.length} ${plural(reply.actions.length, "action")}.`,
136
136
  );
@@ -186,7 +186,7 @@ export async function narrate(
186
186
  type: "audio/mpeg",
187
187
  });
188
188
 
189
- await report(
189
+ await stepReport(
190
190
  `Recorded a ${Math.round(spoken.durationMs / 1000)}s audit in ${spoken.voice}'s voice — ` +
191
191
  `${formatBytes(bytes)} of MP3, from ${formatBytes(spoken.audio.byteLength)} of WAV.`,
192
192
  );
@@ -11,7 +11,7 @@
11
11
  * the DevKit, and that belongs somewhere a spec can reach it.
12
12
  */
13
13
 
14
- import { stepTranscribeSyncClassified } from "@alexkroman1/aai/step-errors";
14
+ import { stepTranscribeSyncOrFail } from "@alexkroman1/aai/step-errors";
15
15
 
16
16
  /**
17
17
  * Transcribe one complete WAV.
@@ -21,7 +21,7 @@ import { stepTranscribeSyncClassified } from "@alexkroman1/aai/step-errors";
21
21
  * stores headerless PCM on purpose (see `media.ts`) and puts a header back with
22
22
  * `encodeWav` for exactly this call.
23
23
  *
24
- * `stepTranscribeSyncClassified` — the SDK's own `stepTranscribeSync` plus
24
+ * `stepTranscribeSyncOrFail` — the SDK's own `stepTranscribeSync` plus
25
25
  * `throwStepError`, and nothing else — is the whole of what this adds, and it is
26
26
  * where the three-way call is made: a `FatalError` stops the DevKit retrying something that
27
27
  * will answer the same way, a bare `RetryableError` retries in ONE SECOND (that
@@ -39,6 +39,6 @@ export async function transcribeSpan(
39
39
  filename: string,
40
40
  label: string,
41
41
  ): Promise<string> {
42
- const { text } = await stepTranscribeSyncClassified(bytes, { filename, label });
42
+ const { text } = await stepTranscribeSyncOrFail(bytes, { filename, label });
43
43
  return text;
44
44
  }
@@ -6,7 +6,7 @@ import type {
6
6
  ToolInputSchema,
7
7
  } from "@alexkroman1/aai";
8
8
  import { isToolFailure } from "@alexkroman1/aai";
9
- import { createToolContext, ok } from "@alexkroman1/aai/testing";
9
+ import { createToolContext, expectToolOk } from "@alexkroman1/aai/testing";
10
10
  import { describe, expect, test } from "vitest";
11
11
  import { callFlow, dispatchSlot } from "./shared.ts";
12
12
  import incidentAddNote from "./tools/incident_add_note.ts";
@@ -32,7 +32,7 @@ const makeCtx = (): ToolContext => createToolContext();
32
32
  * shapes the assertions below used to restate, which were a second copy of each
33
33
  * tool's return type that could not go stale loudly.
34
34
  *
35
- * The unwrap itself is `ok` from `@alexkroman1/aai/testing`; the hand-rolled
35
+ * The unwrap itself is `expectToolOk` from `@alexkroman1/aai/testing`; the hand-rolled
36
36
  * copy that used to sit here was byte-identical to three other templates'.
37
37
  */
38
38
  type Result<T extends ToolDef<ToolInputSchema>> = Extract<
@@ -78,7 +78,7 @@ describe("dispatch-center template", () => {
78
78
  const ctx = makeCtx();
79
79
  const incidentId = await createIncidentFor(ctx, "cardiac arrest, patient not breathing");
80
80
 
81
- const result = ok<Result<typeof resourcesDispatch>>(
81
+ const result = expectToolOk<Result<typeof resourcesDispatch>>(
82
82
  await resourcesDispatch.execute({ incidentId, callsigns: ["auto"] }, ctx),
83
83
  );
84
84
 
@@ -205,13 +205,13 @@ describe("the call flow", () => {
205
205
  expect(created.state).toBe("working.triaging");
206
206
  expect(created.instruction).toMatch(/incident_triage/);
207
207
 
208
- const triaged = ok<Result<typeof incidentTriage>>(
208
+ const triaged = expectToolOk<Result<typeof incidentTriage>>(
209
209
  await incidentTriage.execute({ incidentId: created.incidentId, severity: "critical" }, ctx),
210
210
  );
211
211
  expect(triaged.triageScore).toBeGreaterThan(0);
212
212
  expect(at(ctx).state).toBe("working.dispatching");
213
213
 
214
- ok(
214
+ expectToolOk(
215
215
  await resourcesDispatch.execute({ incidentId: created.incidentId, autoDispatch: true }, ctx),
216
216
  );
217
217
  expect(at(ctx).state).toBe("working.monitoring");
@@ -226,7 +226,7 @@ describe("the call flow", () => {
226
226
 
227
227
  // Every requested callsign is unknown, so no unit moved — and the call has
228
228
  // not moved on either.
229
- const result = ok<Result<typeof resourcesDispatch>>(
229
+ const result = expectToolOk<Result<typeof resourcesDispatch>>(
230
230
  await resourcesDispatch.execute({ incidentId, callsigns: ["Ghost-1"] }, ctx),
231
231
  );
232
232
  expect(result.dispatched).toHaveLength(0);
@@ -245,7 +245,7 @@ describe("the call flow", () => {
245
245
 
246
246
  // The first incident is still workable — the position tracks the call in
247
247
  // hand, and the tools are addressed by id.
248
- ok(await incidentAddNote.execute({ incidentId: first, note: "crews on scene" }, ctx));
248
+ expectToolOk(await incidentAddNote.execute({ incidentId: first, note: "crews on scene" }, ctx));
249
249
  });
250
250
 
251
251
  test("a failed tool does not advance the flow", async () => {
@@ -1,12 +1,16 @@
1
+ import { plural } from "@alexkroman1/aai/utils";
1
2
  import "@alexkroman1/aai-ui/styles.css";
2
- import type { AgentState, ConversationItem, Session } from "@alexkroman1/aai-ui";
3
+ import type { AgentState, ConversationItem } from "@alexkroman1/aai-ui";
3
4
  import {
5
+ AGENT_STATE_LABELS,
4
6
  AutoScroll,
5
- client,
7
+ mountClient,
8
+ SessionErrorBanner,
6
9
  useAgentState,
7
10
  useConversation,
8
- useSession,
11
+ useSessionActions,
9
12
  useSessionSelector,
13
+ useSessionStatus,
10
14
  } from "@alexkroman1/aai-ui";
11
15
  import type { DispatchState, IncidentSummary, Severity, Status } from "./shared.ts";
12
16
  import { dashboardProjection } from "./shared.ts";
@@ -77,6 +81,20 @@ const STATE_COLORS = {
77
81
  error: "#6b7280",
78
82
  } satisfies Record<AgentState, string>;
79
83
 
84
+ /*
85
+ * The board's own vocabulary, spread over the package's record rather than
86
+ * written as a ternary chain. Only three of the seven states get a dispatch
87
+ * word; the rest come from `AGENT_STATE_LABELS`, so a state added upstream
88
+ * reads as something rather than falling through to whichever arm the chain
89
+ * ended on.
90
+ */
91
+ const STATE_LABELS: Record<AgentState, string> = {
92
+ ...AGENT_STATE_LABELS,
93
+ listening: "LISTENING",
94
+ thinking: "PROCESSING",
95
+ speaking: "TRANSMITTING",
96
+ };
97
+
80
98
  function Panel({ title, children }: { title: string; children: React.ReactNode }) {
81
99
  return (
82
100
  <div className="rounded-lg p-3" style={{ background: "#1a1a2e", border: "1px solid #1e293b" }}>
@@ -230,8 +248,16 @@ function Conversation() {
230
248
  {streaming}
231
249
  </div>
232
250
  )}
251
+ {/* Same contract as the shipped `MessageList`'s indicator: three pulsing
252
+ dots are the only sign the desk is working, and to a screen reader
253
+ they are punctuation. */}
233
254
  {thinking && (
234
- <div className="self-start text-[11px] px-3.5" style={{ color: "#64748b" }}>
255
+ <div
256
+ role="status"
257
+ aria-label="Dispatch is thinking"
258
+ className="self-start text-[11px] px-3.5"
259
+ style={{ color: "#64748b" }}
260
+ >
235
261
  <span style={{ animation: "dc-pulse 1.2s ease-in-out infinite" }}>· · ·</span>
236
262
  </div>
237
263
  )}
@@ -261,7 +287,7 @@ function Conversation() {
261
287
  * it one level up dragged the incident board through every snapshot change.
262
288
  */
263
289
  function StatusReadout() {
264
- const state = useSessionSelector((s) => s.state);
290
+ const state = useSessionStatus();
265
291
  return (
266
292
  <>
267
293
  <span
@@ -278,70 +304,36 @@ function StatusReadout() {
278
304
  title={state}
279
305
  />
280
306
  <span className="text-[11px] font-normal normal-case" style={{ color: "#64748b" }}>
281
- {state === "listening"
282
- ? "LISTENING"
283
- : state === "thinking"
284
- ? "PROCESSING"
285
- : state === "speaking"
286
- ? "TRANSMITTING"
287
- : state.toUpperCase()}
307
+ {STATE_LABELS[state]}
288
308
  </span>
289
309
  </>
290
310
  );
291
311
  }
292
312
 
293
- /**
294
- * The fatal-error banner.
313
+ /*
314
+ * The shift controls.
295
315
  *
296
- * `role="alert"` is the part that is easy to leave out and is load-bearing: per
297
- * the `fatalError` latch in the session core this banner is the only remaining
298
- * signal the status eyebrow beside it goes back to reading like a live
299
- * session and a screen reader is never told an unannounced one appeared. It
300
- * is what `ConsoleShell` carries, which this template's full-bleed two-pane
301
- * board cannot use as its frame.
316
+ * `useSessionActions()` for the methods and two one-field selectors for the
317
+ * flags, rather than the whole-snapshot `useSession()` this used to hold. The
318
+ * methods are what the row is really after, and `useSession()` re-renders on
319
+ * every snapshot change so four buttons re-rendered at STT-partial rate to
320
+ * read two booleans that flip once a shift.
302
321
  */
303
- function ErrorBanner() {
304
- const error = useSessionSelector((s) => s.error);
305
- if (!error) return null;
306
- return (
307
- <div
308
- role="alert"
309
- className="px-4 py-2 text-xs"
310
- style={{ background: "#450a0a", color: "#fca5a5", borderTop: "1px solid #991b1b" }}
311
- >
312
- ERROR: {error.message} ({error.code})
313
- </div>
314
- );
315
- }
316
-
317
- /**
318
- * Start a fresh conversation without leaving the console.
319
- *
320
- * Written out here rather than reached for on the session, because there is no
321
- * one method that does it: `reset()` clears the CONVERSATION and keeps the
322
- * session, which is wrong for any agent that also keeps session-scoped state —
323
- * this one's incident board would come back with the next tool call.
324
- */
325
- function newConversation(session: Session): void {
326
- session.end();
327
- session.start();
328
- }
329
-
330
- /** The shift controls. The one place a whole-session read is what is wanted:
331
- * it needs `started`, `running` and four methods, and it is four buttons. */
332
322
  function ShiftControls({ logged }: { logged: number }) {
333
- const session = useSession();
323
+ const { start, toggle, restart, end } = useSessionActions();
324
+ const started = useSessionSelector((s) => s.started);
325
+ const running = useSessionSelector((s) => s.running);
334
326
  return (
335
327
  <div
336
328
  className="flex items-center gap-2.5 px-4 py-3"
337
329
  style={{ background: "#111827", borderTop: "1px solid #1e293b" }}
338
330
  >
339
- {!session.started ? (
331
+ {!started ? (
340
332
  <button
341
333
  type="button"
342
334
  className="px-4 py-2 border-none rounded-md font-mono text-xs font-semibold uppercase tracking-wider cursor-pointer text-white"
343
335
  style={{ background: "#2563eb" }}
344
- onClick={() => session.start()}
336
+ onClick={() => start()}
345
337
  >
346
338
  Start Dispatch
347
339
  </button>
@@ -351,12 +343,12 @@ function ShiftControls({ logged }: { logged: number }) {
351
343
  type="button"
352
344
  className="px-4 py-2 border-none rounded-md font-mono text-xs font-semibold uppercase tracking-wider cursor-pointer"
353
345
  style={{
354
- background: session.running ? "#334155" : "#2563eb",
355
- color: session.running ? "#e2e8f0" : "white",
346
+ background: running ? "#334155" : "#2563eb",
347
+ color: running ? "#e2e8f0" : "white",
356
348
  }}
357
- onClick={() => session.toggle()}
349
+ onClick={() => toggle()}
358
350
  >
359
- {session.running ? "Pause" : "Resume"}
351
+ {running ? "Pause" : "Resume"}
360
352
  </button>
361
353
  {/* The one-click new conversation the default shell's
362
354
  `<Controls>` gives every other template — a custom
@@ -375,7 +367,7 @@ function ShiftControls({ logged }: { logged: number }) {
375
367
  type="button"
376
368
  className="px-4 py-2 border-none rounded-md font-mono text-xs font-semibold uppercase tracking-wider cursor-pointer"
377
369
  style={{ background: "#1e293b", color: "#e2e8f0" }}
378
- onClick={() => newConversation(session)}
370
+ onClick={restart}
379
371
  >
380
372
  New Conversation
381
373
  </button>
@@ -388,7 +380,7 @@ function ShiftControls({ logged }: { logged: number }) {
388
380
  type="button"
389
381
  className="px-4 py-2 border-none rounded-md font-mono text-xs font-semibold uppercase tracking-wider cursor-pointer text-white"
390
382
  style={{ background: "#dc2626" }}
391
- onClick={() => session.end()}
383
+ onClick={() => end()}
392
384
  >
393
385
  End Shift
394
386
  </button>
@@ -396,7 +388,7 @@ function ShiftControls({ logged }: { logged: number }) {
396
388
  )}
397
389
  <div className="flex-1" />
398
390
  <span className="text-[10px]" style={{ color: "#475569" }}>
399
- {logged} incident{logged !== 1 ? "s" : ""} logged
391
+ {logged} {plural(logged, "incident")} logged
400
392
  </span>
401
393
  </div>
402
394
  );
@@ -471,7 +463,7 @@ function App() {
471
463
  style={{ borderRight: "1px solid #1e293b" }}
472
464
  >
473
465
  <Conversation />
474
- <ErrorBanner />
466
+ <SessionErrorBanner className="rounded-none border-x-0 border-b-0" />
475
467
  <ShiftControls logged={incidentList.length} />
476
468
  </div>
477
469
 
@@ -524,7 +516,7 @@ function App() {
524
516
  );
525
517
  }
526
518
 
527
- client({
519
+ mountClient({
528
520
  component: App,
529
521
  theme: {
530
522
  bg: "#0a0a0f",
@@ -71,7 +71,6 @@ export interface Incident {
71
71
  createdAt: number;
72
72
  updatedAt: number;
73
73
  escalationLevel: number;
74
- protocolsActivated: string[];
75
74
  casualties: { confirmed: number; estimated: number; treated: number };
76
75
  hazards: string[];
77
76
  }
@@ -82,7 +81,6 @@ export interface DispatchState {
82
81
  incidentCounter: number;
83
82
  mutualAidCounter: number;
84
83
  alertLevel: "green" | "yellow" | "orange" | "red";
85
- mutualAidRequested: boolean;
86
84
  }
87
85
 
88
86
  /**
@@ -157,7 +155,6 @@ export function createDefaultState(): DispatchState {
157
155
  incidentCounter: 0,
158
156
  mutualAidCounter: 0,
159
157
  alertLevel: "green",
160
- mutualAidRequested: false,
161
158
  };
162
159
  }
163
160
 
@@ -348,7 +345,6 @@ export function createIncident(state: DispatchState, overrides: Partial<Incident
348
345
  createdAt: time,
349
346
  updatedAt: time,
350
347
  escalationLevel: 0,
351
- protocolsActivated: [],
352
348
  casualties: { confirmed: 0, estimated: 0, treated: 0 },
353
349
  hazards: [],
354
350
  ...overrides,
@@ -730,8 +726,4 @@ export function recalculateAlertLevel(state: DispatchState): void {
730
726
  } else {
731
727
  state.alertLevel = "green";
732
728
  }
733
-
734
- // Mutual aid tracks system posture: requested at red alert, stood down
735
- // when the alert level drops back below red.
736
- state.mutualAidRequested = state.alertLevel === "red";
737
729
  }
@@ -25,7 +25,10 @@ export default dispatchSlot.tool({
25
25
 
26
26
  return {
27
27
  systemAlertLevel: state.alertLevel,
28
- mutualAidActive: state.mutualAidRequested,
28
+ // Derived, not stored: mutual aid is requested at red alert and stood
29
+ // down when the level drops back below it, so the alert level IS the
30
+ // fact and the two can never disagree.
31
+ mutualAidActive: state.alertLevel === "red",
29
32
  resourceUtilization: `${utilization}%`,
30
33
  resourceSummary,
31
34
  activeIncidentCount: activeIncidents.length,
@@ -13,8 +13,10 @@
13
13
  /**
14
14
  * The def a DEPLOYED agent runs: authored, plus what `tools/` declares.
15
15
  *
16
- * The glob is written HERE rather than reached for from a shared helper because
17
- * this file SHIPS a scaffolded project has no repo helper to import. Without
16
+ * Taken from `virtual:aai/agent` rather than a hand-written glob: the plugin
17
+ * expands it against THIS file's own directory, so the spec needs no glob and
18
+ * no shared helper — which matters because this file SHIPS, and a scaffolded
19
+ * project has no repo helper to import. Without
18
20
  * it this agent would have no tools and would answer every question from the
19
21
  * model's own memory, which is exactly what the cases below forbid.
20
22
  *
@@ -22,8 +22,10 @@
22
22
  /**
23
23
  * The def a DEPLOYED agent runs: authored, plus what `tools/` declares.
24
24
  *
25
- * The glob is written HERE rather than reached for from a shared helper because
26
- * this file SHIPS a scaffolded project has no repo helper to import. Without
25
+ * Taken from `virtual:aai/agent` rather than a hand-written glob: the plugin
26
+ * expands it against THIS file's own directory, so the spec needs no glob and
27
+ * no shared helper — which matters because this file SHIPS, and a scaffolded
28
+ * project has no repo helper to import. Without
27
29
  * it the desk would have no tools and would answer every one of these from
28
30
  * memory, which is the failure these cases exist to catch.
29
31
  *