@alexkroman1/aai-cli 13.3.0 → 15.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{_agent-Clbq3a4h.mjs → _agent-BzUeqOdj.mjs} +32 -9
- package/dist/_agent.d.ts +23 -0
- package/dist/_artifacts-BJOYGQPp.mjs +21 -0
- package/dist/_artifacts.d.ts +16 -0
- package/dist/_build-target.d.ts +172 -0
- package/dist/{_bundler-DEkjO1d8.mjs → _bundler-DM0d0M7m.mjs} +8 -7
- package/dist/_cli-common.d.ts +45 -6
- package/dist/{_config-ZY2euhdh.mjs → _config-DYzC6WMD.mjs} +2 -2
- package/dist/_dev-env.d.ts +1 -1
- package/dist/{_dev-server-C13xuGB1.mjs → _dev-server-BzWB6-4y.mjs} +25 -24
- package/dist/_dev-vite-config.d.ts +3 -3
- package/dist/_e2e-test-utils.d.ts +1 -1
- package/dist/{_init-Ci4QGjp8.mjs → _init-Bsi3DZNJ.mjs} +9 -23
- package/dist/{_output-DbJt9gsl.mjs → _output-DBP9Op_d.mjs} +22 -13
- package/dist/_output.d.ts +10 -9
- package/dist/{_preflight-B2laWPdU.mjs → _preflight-CcAR577V.mjs} +2 -2
- package/dist/_resource-commands.d.ts +9 -9
- package/dist/_server-common-De0haHr9.mjs +70 -0
- package/dist/_server-common.d.ts +20 -1
- package/dist/{_slug-api-DlKpj91X.mjs → _slug-api-CGvc2YSQ.mjs} +18 -9
- package/dist/_slug-api.d.ts +19 -0
- package/dist/{_studio-DTi3EdXQ.mjs → _studio-DnR_BqFp.mjs} +7 -3
- package/dist/_studio-commands.d.ts +13 -13
- package/dist/{_templates-V4l5sZJy.mjs → _templates-CIlJ3Vay.mjs} +4 -4
- package/dist/_templates.d.ts +1 -1
- package/dist/{_typecheck-gate-sV4N5F93.mjs → _typecheck-gate-U3jcil8L.mjs} +3 -2
- package/dist/_typecheck-gate.d.ts +3 -1
- package/dist/{_utils-Dhd058D9.mjs → _utils-D5JGcjiW.mjs} +43 -1
- package/dist/_utils.d.ts +35 -0
- package/dist/_vercel-output.d.ts +63 -0
- package/dist/{_workflow-determinism-C9l8uC2R.mjs → _workflow-determinism-CvpqMpdh.mjs} +18 -26
- package/dist/_workflow-determinism.d.ts +3 -3
- package/dist/build-BhEaxBPu.mjs +481 -0
- package/dist/build.d.ts +23 -10
- package/dist/cli.d.ts +1 -2
- package/dist/cli.mjs +155 -138
- package/dist/{client-bundler-CT-_26c5.mjs → client-bundler-6mTLs6ny.mjs} +5 -5
- package/dist/client-bundler.d.ts +1 -1
- package/dist/client-bundler.mjs +1 -1
- package/dist/{delete-RqlQZ6Y_.mjs → delete-BdFnbnVR.mjs} +4 -4
- package/dist/{deploy-B7f3CK7v.mjs → deploy-CGqPU5U-.mjs} +11 -11
- package/dist/{dev-DamXieW_.mjs → dev-Bx9gYBHM.mjs} +3 -3
- package/dist/{eval-h3DTVfRw.mjs → eval-B3I7FqN9.mjs} +3 -3
- package/dist/{init-D-vFrV8h.mjs → init-CFyusRbq.mjs} +48 -20
- package/dist/init.d.ts +14 -0
- package/dist/{login-CDiemNmC.mjs → login-DaLrPOFg.mjs} +4 -6
- package/dist/{logs-DGwPNCzB.mjs → logs-7BRwq0cG.mjs} +21 -14
- package/dist/project-config.mjs +1 -1
- package/dist/scaffold/CLAUDE.md +155 -76
- package/dist/scaffold/package.json +6 -6
- package/dist/{secret-V1w7y9-j.mjs → secret-Cjp912yF.mjs} +5 -5
- package/dist/secret.d.ts +8 -6
- package/dist/start.d.ts +112 -0
- package/dist/start.mjs +156 -0
- package/dist/{studio-BpMCfUNx.mjs → studio-C_zuRC_z.mjs} +13 -15
- package/dist/templates/briefing-desk/agent.eval.test.ts +156 -0
- package/dist/templates/call-audit/agent.test.ts +11 -6
- package/dist/templates/call-audit/agent.ts +2 -2
- package/dist/templates/call-audit/client.tsx +27 -42
- package/dist/templates/call-audit/workflows/audit.ts +7 -5
- package/dist/templates/call-audit/workflows/ingest.ts +7 -7
- package/dist/templates/call-audit/workflows/media.ts +12 -37
- package/dist/templates/call-audit/workflows/summarize.ts +8 -8
- package/dist/templates/call-audit/workflows/sync-api.ts +3 -3
- package/dist/templates/code-interpreter/agent.test.ts +103 -0
- package/dist/templates/dispatch-center/agent.test.ts +7 -7
- package/dist/templates/dispatch-center/client.tsx +53 -61
- package/dist/templates/dispatch-center/shared.ts +0 -8
- package/dist/templates/dispatch-center/tools/ops_dashboard.ts +4 -1
- package/dist/templates/embedded-assets/agent.eval.test.ts +4 -2
- package/dist/templates/health-assistant/agent.eval.test.ts +4 -2
- package/dist/templates/infocom-adventure/client.tsx +40 -36
- package/dist/templates/link-digest/agent.test.ts +3 -3
- package/dist/templates/link-digest/agent.ts +2 -2
- package/dist/templates/link-digest/client.tsx +81 -19
- package/dist/templates/link-digest/workflows/digest.ts +13 -16
- package/dist/templates/math-buddy/agent.test.ts +126 -0
- package/dist/templates/night-owl/agent.eval.test.ts +4 -2
- package/dist/templates/night-owl/client.tsx +8 -2
- package/dist/templates/personal-finance/agent.test.ts +127 -0
- package/dist/templates/pipeline-simple/agent.test.ts +2 -2
- package/dist/templates/pizza-ordering/client.tsx +5 -4
- package/dist/templates/pizza-ordering/shared.ts +3 -8
- package/dist/templates/pizza-ordering/tools/add_pizza.ts +3 -2
- package/dist/templates/pizza-ordering/tools/place_order.ts +3 -2
- package/dist/templates/pizza-ordering/tools/remove_pizza.ts +3 -2
- package/dist/templates/pizza-ordering/tools/update_pizza.ts +3 -2
- package/dist/templates/pizza-ordering/tools/view_order.ts +3 -2
- package/dist/templates/plan-and-execute/agent.test.ts +10 -5
- package/dist/templates/plan-and-execute/client.tsx +2 -2
- package/dist/templates/podcast-digest/agent.test.ts +20 -11
- package/dist/templates/podcast-digest/client.tsx +16 -22
- package/dist/templates/podcast-digest/workflows/digest.ts +21 -19
- package/dist/templates/podcast-digest/workflows/feeds.ts +61 -33
- package/dist/templates/podcast-digest/workflows/slack.ts +7 -7
- package/dist/templates/recap-workflow/agent.eval.test.ts +21 -13
- package/dist/templates/recap-workflow/agent.test.ts +27 -23
- package/dist/templates/recap-workflow/workflows/recap.ts +24 -28
- package/dist/templates/redline/agent.eval.test.ts +5 -8
- package/dist/templates/redline/agent.test.ts +1 -1
- package/dist/templates/redline/client.tsx +36 -34
- package/dist/templates/redline/workflows/redline.ts +17 -17
- package/dist/templates/research-workflow/agent.eval.test.ts +5 -5
- package/dist/templates/research-workflow/agent.test.ts +10 -7
- package/dist/templates/research-workflow/workflows/research.ts +28 -30
- package/dist/templates/retail/agent.test.ts +17 -15
- package/dist/templates/retail/cancel.ts +7 -6
- package/dist/templates/retail/client.tsx +39 -58
- package/dist/templates/retail/payment.ts +4 -3
- package/dist/templates/retail/pending.test.ts +19 -19
- package/dist/templates/retail/store.test.ts +5 -5
- package/dist/templates/retail/swap.ts +8 -11
- package/dist/templates/solo-rpg/agent.eval.test.ts +2 -16
- package/dist/templates/solo-rpg/agent.test.ts +12 -12
- package/dist/templates/solo-rpg/client.tsx +3 -3
- package/dist/templates/spoken-summary/agent.test.ts +9 -9
- package/dist/templates/spoken-summary/agent.ts +3 -3
- package/dist/templates/spoken-summary/client.tsx +23 -30
- package/dist/templates/spoken-summary/workflows/summarize.ts +11 -11
- package/dist/templates/spoken-summary/workflows/transcribe.ts +14 -14
- package/dist/templates/support-line/agent.ts +8 -0
- package/dist/templates/support-line/client.tsx +4 -3
- package/dist/templates/support-line/prompts.ts +0 -2
- package/dist/templates/transcription-workflow/agent.test.ts +20 -12
- package/dist/templates/transcription-workflow/agent.ts +2 -2
- package/dist/templates/transcription-workflow/client.tsx +15 -14
- package/dist/templates/transcription-workflow/recover.ts +1 -1
- package/dist/templates/transcription-workflow/run-panel.tsx +34 -14
- package/dist/templates/transcription-workflow/total-latency.tsx +46 -20
- package/dist/templates/transcription-workflow/workflows/batch.ts +15 -15
- package/dist/templates/transcription-workflow/workflows/downsample.ts +1 -1
- package/dist/templates/transcription-workflow/workflows/normalize.ts +15 -19
- package/dist/templates/transcription-workflow/workflows/stream.ts +12 -12
- package/dist/templates/transcription-workflow/workflows/sync-api.ts +3 -3
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +23 -34
- package/dist/templates/travel-concierge/agent.eval.test.ts +2 -2
- package/dist/templates/travel-concierge/agent.test.ts +15 -7
- package/dist/templates/travel-concierge/client.tsx +2 -2
- package/dist/templates/travel-concierge/routing.ts +64 -55
- package/dist/templates/travel-concierge/shared.ts +10 -14
- package/dist/templates/travel-concierge/tools/cancel_action.ts +3 -1
- package/dist/templates/travel-concierge/tools/complete_or_escalate.ts +3 -1
- package/dist/templates/travel-concierge/tools/confirm_action.ts +3 -1
- package/dist/templates/travel-concierge/tools/lookup_booking.ts +4 -3
- package/dist/templates/travel-concierge/tools/search_car_rentals.ts +3 -2
- package/dist/templates/travel-concierge/tools/search_excursions.ts +3 -2
- package/dist/templates/travel-concierge/tools/search_flights.ts +3 -2
- package/dist/templates/travel-concierge/tools/search_hotels.ts +3 -2
- package/dist/templates/web-researcher/agent.test.ts +130 -0
- package/dist/{test-guenJUxi.mjs → test-D032V2cA.mjs} +4 -17
- package/dist/typecheck.mjs +1 -1
- package/dist/worker-bundler.d.ts +1 -1
- package/dist/worker-bundler.mjs +8 -8
- package/dist/{workflow-BZyKLkhV.mjs → workflow-CVioyO3V.mjs} +6 -5
- package/package.json +9 -4
- package/dist/_server-common-BV4_CrP3.mjs +0 -43
- package/dist/build-CWyFGe-W.mjs +0 -69
- package/dist/scaffold/server.mjs +0 -204
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { formatMoney } from "@alexkroman1/aai/utils";
|
|
1
2
|
import { z } from "zod";
|
|
2
|
-
import { CRUSTS, calculateTotal,
|
|
3
|
+
import { CRUSTS, calculateTotal, orderSlot, type Pizza, SIZES } from "../shared.ts";
|
|
3
4
|
|
|
4
5
|
export default orderSlot.updateTool({
|
|
5
6
|
description: "Add a pizza to the order. Use when the customer has decided on a pizza.",
|
|
@@ -24,7 +25,7 @@ export default orderSlot.updateTool({
|
|
|
24
25
|
|
|
25
26
|
return {
|
|
26
27
|
added: pizza,
|
|
27
|
-
orderTotal:
|
|
28
|
+
orderTotal: formatMoney(calculateTotal(order.pizzas)),
|
|
28
29
|
itemCount: order.pizzas.length,
|
|
29
30
|
};
|
|
30
31
|
},
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { toolFailure } from "@alexkroman1/aai";
|
|
2
|
-
import {
|
|
2
|
+
import { formatMoney } from "@alexkroman1/aai/utils";
|
|
3
|
+
import { calculateTotal, orderSlot, resetOrder } from "../shared.ts";
|
|
3
4
|
|
|
4
5
|
export default orderSlot.updateTool({
|
|
5
6
|
description:
|
|
@@ -9,7 +10,7 @@ export default orderSlot.updateTool({
|
|
|
9
10
|
if (pizzas.length === 0) return toolFailure("Cannot place an empty order.");
|
|
10
11
|
|
|
11
12
|
const customerName = order.customerName ?? "Guest";
|
|
12
|
-
const total =
|
|
13
|
+
const total = formatMoney(calculateTotal(pizzas));
|
|
13
14
|
const orderNumber = Math.floor(1000 + Math.random() * 9000);
|
|
14
15
|
|
|
15
16
|
const estimatedMinutes = 15 + pizzas.length * 5;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { toolFailure } from "@alexkroman1/aai";
|
|
2
|
+
import { formatMoney } from "@alexkroman1/aai/utils";
|
|
2
3
|
import { z } from "zod";
|
|
3
|
-
import { calculateTotal,
|
|
4
|
+
import { calculateTotal, orderSlot } from "../shared.ts";
|
|
4
5
|
|
|
5
6
|
export default orderSlot.updateTool({
|
|
6
7
|
description: "Remove a pizza from the order by its ID.",
|
|
@@ -15,7 +16,7 @@ export default orderSlot.updateTool({
|
|
|
15
16
|
|
|
16
17
|
return {
|
|
17
18
|
removed,
|
|
18
|
-
orderTotal:
|
|
19
|
+
orderTotal: formatMoney(calculateTotal(order.pizzas)),
|
|
19
20
|
itemCount: order.pizzas.length,
|
|
20
21
|
};
|
|
21
22
|
},
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { toolFailure } from "@alexkroman1/aai";
|
|
2
|
+
import { formatMoney } from "@alexkroman1/aai/utils";
|
|
2
3
|
import { z } from "zod";
|
|
3
|
-
import { CRUSTS, calculateTotal,
|
|
4
|
+
import { CRUSTS, calculateTotal, orderSlot, type Pizza, SIZES } from "../shared.ts";
|
|
4
5
|
|
|
5
6
|
export default orderSlot.updateTool({
|
|
6
7
|
description: "Update an existing pizza in the order. Only provided fields are changed.",
|
|
@@ -23,6 +24,6 @@ export default orderSlot.updateTool({
|
|
|
23
24
|
|
|
24
25
|
order.pizzas[idx] = pizza;
|
|
25
26
|
|
|
26
|
-
return { updated: pizza, orderTotal:
|
|
27
|
+
return { updated: pizza, orderTotal: formatMoney(calculateTotal(order.pizzas)) };
|
|
27
28
|
},
|
|
28
29
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { formatMoney } from "@alexkroman1/aai/utils";
|
|
2
|
+
import { calculateTotal, orderSlot } from "../shared.ts";
|
|
2
3
|
|
|
3
4
|
export default orderSlot.tool({
|
|
4
5
|
description: "View the current order summary with all pizzas and total price.",
|
|
@@ -15,7 +16,7 @@ export default orderSlot.tool({
|
|
|
15
16
|
toppings: p.toppings,
|
|
16
17
|
quantity: p.quantity,
|
|
17
18
|
})),
|
|
18
|
-
orderTotal:
|
|
19
|
+
orderTotal: formatMoney(calculateTotal(pizzas)),
|
|
19
20
|
};
|
|
20
21
|
},
|
|
21
22
|
});
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
/** The def a DEPLOYED agent runs: authored, plus what `tools/` declares. */
|
|
2
2
|
import agentDef from "virtual:aai/agent";
|
|
3
3
|
import type { ToolContext } from "@alexkroman1/aai";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
createToolContext,
|
|
6
|
+
expectDialogOk,
|
|
7
|
+
stubGenerate,
|
|
8
|
+
toolRunner,
|
|
9
|
+
} from "@alexkroman1/aai/testing";
|
|
5
10
|
import { describe, expect, test } from "vitest";
|
|
6
11
|
|
|
7
12
|
import { executeStep, MAX_STEP_SEARCHES, normalizeAct, planNode } from "./procedure.ts";
|
|
@@ -216,7 +221,7 @@ describe("work_next_step", () => {
|
|
|
216
221
|
const ctx = createToolContext({ generate });
|
|
217
222
|
await run("start_plan", { objective: "a weekend in Lisbon" }, ctx);
|
|
218
223
|
|
|
219
|
-
const first =
|
|
224
|
+
const first = expectDialogOk<{
|
|
220
225
|
finished: boolean;
|
|
221
226
|
step: string;
|
|
222
227
|
result: string;
|
|
@@ -243,7 +248,7 @@ describe("work_next_step", () => {
|
|
|
243
248
|
});
|
|
244
249
|
const ctx = createToolContext({ generate });
|
|
245
250
|
await run("start_plan", { objective: "a weekend in Lisbon" }, ctx);
|
|
246
|
-
const answered =
|
|
251
|
+
const answered = expectDialogOk<{ finished: boolean; response: string }>(
|
|
247
252
|
await run("work_next_step", ctx),
|
|
248
253
|
);
|
|
249
254
|
|
|
@@ -327,7 +332,7 @@ describe("revise_plan", () => {
|
|
|
327
332
|
await run("work_next_step", ctx);
|
|
328
333
|
expect(stateOf(ctx).response).not.toBeNull();
|
|
329
334
|
|
|
330
|
-
const revised =
|
|
335
|
+
const revised = expectDialogOk<{ finished: boolean; remaining: string[] }>(
|
|
331
336
|
await run("revise_plan", { instruction: "make it Porto instead" }, ctx),
|
|
332
337
|
);
|
|
333
338
|
expect(revised.result.finished).toBe(false);
|
|
@@ -362,7 +367,7 @@ describe("revise_plan", () => {
|
|
|
362
367
|
});
|
|
363
368
|
const ctx = createToolContext({ generate });
|
|
364
369
|
await run("start_plan", { objective: "a weekend in Lisbon" }, ctx);
|
|
365
|
-
const revised =
|
|
370
|
+
const revised = expectDialogOk<{ finished: boolean }>(
|
|
366
371
|
await run("revise_plan", { instruction: "never mind, it is booked" }, ctx),
|
|
367
372
|
);
|
|
368
373
|
expect(revised.result.finished).toBe(true);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import "@alexkroman1/aai-ui/styles.css";
|
|
2
|
-
import { AutoScroll,
|
|
2
|
+
import { AutoScroll, mountClient, useAgentState } from "@alexkroman1/aai-ui";
|
|
3
3
|
import { planProjection } from "./shared.ts";
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -90,7 +90,7 @@ function PlanSidebar() {
|
|
|
90
90
|
);
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
|
|
93
|
+
mountClient({
|
|
94
94
|
name: "Planning Desk",
|
|
95
95
|
sidebar: PlanSidebar,
|
|
96
96
|
theme: {
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* HTTP handling, its partial-failure policy and its `FatalError` guards are
|
|
12
12
|
* all reachable — `installStubStepFetch` answers the network and
|
|
13
13
|
* `stubGateway` answers the model.
|
|
14
|
-
* - The BODY, twice. `
|
|
14
|
+
* - The BODY, twice. `createWorkflowContext` records what it asked for (the digest
|
|
15
15
|
* loop, the shrinking pending set, the sleep BETWEEN digests and never after
|
|
16
16
|
* the last), and `runWorkflow` runs it on the real replay engine — which is
|
|
17
17
|
* what this file used to say a unit test could not do, on the ground that a
|
|
@@ -26,8 +26,9 @@
|
|
|
26
26
|
|
|
27
27
|
import { type FeedItem, parseFeed } from "@alexkroman1/aai/html";
|
|
28
28
|
import {
|
|
29
|
-
|
|
29
|
+
createWorkflowContext,
|
|
30
30
|
parseSchemaInput,
|
|
31
|
+
routeStepFetch,
|
|
31
32
|
schemaInputIssues,
|
|
32
33
|
stubGatewayRoute,
|
|
33
34
|
} from "@alexkroman1/aai/testing";
|
|
@@ -422,12 +423,20 @@ const THREE_EPISODES = feedXml(
|
|
|
422
423
|
* the thing that forgets, and a step fetch left published reaches the next file.
|
|
423
424
|
*/
|
|
424
425
|
function stubRoutes(routes: Record<string, { status?: number; body?: unknown }>) {
|
|
425
|
-
return installStubStepFetch(
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
426
|
+
return installStubStepFetch(
|
|
427
|
+
routeStepFetch(
|
|
428
|
+
Object.entries(routes).map(
|
|
429
|
+
([fragment, answer]) =>
|
|
430
|
+
(request) =>
|
|
431
|
+
request.url.includes(fragment) ? answer : undefined,
|
|
432
|
+
),
|
|
433
|
+
// A 404 rather than the default throw, and this one is deliberate: several
|
|
434
|
+
// cases below are ABOUT a feed the run cannot reach, and they express that
|
|
435
|
+
// by leaving it out of `routes`. Where an unrecognised request would be a
|
|
436
|
+
// finding instead, the default is what says so at the call.
|
|
437
|
+
{ unmatched: "notFound" },
|
|
438
|
+
),
|
|
439
|
+
);
|
|
431
440
|
}
|
|
432
441
|
|
|
433
442
|
describe("discoverEpisodes", () => {
|
|
@@ -837,7 +846,7 @@ describe("posting the digest", () => {
|
|
|
837
846
|
});
|
|
838
847
|
|
|
839
848
|
/**
|
|
840
|
-
* The 4xx/5xx split is the reason this is not a one-line `
|
|
849
|
+
* The 4xx/5xx split is the reason this is not a one-line `stepFetchOrFail`: a
|
|
841
850
|
* revoked webhook answers 4xx identically on every retry, so retrying it
|
|
842
851
|
* burns the DevKit's attempts and delays the real error by minutes.
|
|
843
852
|
*/
|
|
@@ -876,7 +885,7 @@ describe("the body — the run that IS the schedule", () => {
|
|
|
876
885
|
* per-step spec can see, and all of which is the template's actual subject.
|
|
877
886
|
*/
|
|
878
887
|
function driveTwoDigests(pollResults: unknown) {
|
|
879
|
-
const ctx =
|
|
888
|
+
const ctx = createWorkflowContext({
|
|
880
889
|
runSteps: false,
|
|
881
890
|
results: {
|
|
882
891
|
discoverEpisodes: [EPISODE],
|
|
@@ -973,7 +982,7 @@ describe("the body — the run that IS the schedule", () => {
|
|
|
973
982
|
/**
|
|
974
983
|
* The schedule, on the real replay engine.
|
|
975
984
|
*
|
|
976
|
-
* The block above drives the same body through `
|
|
985
|
+
* The block above drives the same body through `createWorkflowContext`, which
|
|
977
986
|
* RECORDS a sleep rather than taking one and replays nothing — right for the
|
|
978
987
|
* loop's logic, and silent about the property this template is: a run that
|
|
979
988
|
* sleeps for a day between digests and comes back. `runWorkflow`
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* The browser half — a form that starts a run, and a panel that watches one
|
|
4
4
|
* that may outlive the browser by a week.
|
|
5
5
|
*
|
|
6
|
-
* Mounted with `
|
|
6
|
+
* Mounted with `mountPage()` rather than `mountClient()`: there is no session to build, so
|
|
7
7
|
* no socket, no audio graph, no microphone request. `useWorkflowSubmit()` starts
|
|
8
8
|
* the run, follows its STATUS, and hands back the controls bound to it.
|
|
9
9
|
*
|
|
@@ -86,8 +86,9 @@
|
|
|
86
86
|
*/
|
|
87
87
|
|
|
88
88
|
import {
|
|
89
|
+
BulletList,
|
|
89
90
|
Form,
|
|
90
|
-
|
|
91
|
+
mountPage,
|
|
91
92
|
SubmitButton,
|
|
92
93
|
useRunKey,
|
|
93
94
|
useWorkflowSubmit,
|
|
@@ -98,7 +99,6 @@ import "@alexkroman1/aai-ui/styles.css";
|
|
|
98
99
|
// ERASED at build time, so naming the agent's own type costs the browser bundle
|
|
99
100
|
// nothing — and it is what stops this file restating a shape `workflows/
|
|
100
101
|
// digest.ts` already declares.
|
|
101
|
-
import { useState } from "react";
|
|
102
102
|
import type { dailyDigest } from "./agent.ts";
|
|
103
103
|
|
|
104
104
|
/** The workflow this page drives. Matches the key in `workflowApp({ workflows })`. */
|
|
@@ -131,17 +131,15 @@ export function App() {
|
|
|
131
131
|
// Did THIS load start the schedule? A later load cannot have, and that is the
|
|
132
132
|
// only way the page can tell "scheduled just now" from "still running from
|
|
133
133
|
// Tuesday" — the hook reports the run, not who asked for it.
|
|
134
|
-
const [startedHere, setStartedHere] = useState(false);
|
|
135
134
|
// The generic is what makes `run.status === "completed"` narrow to a TYPED
|
|
136
135
|
// `run.output` instead of `unknown`. `error` is the agent's own sentence for a
|
|
137
136
|
// rejected input — better copy than anything this page could write, and the
|
|
138
137
|
// reason there is no `try`/`catch` here.
|
|
139
138
|
// The key REPLACES the tab-scoped one the hook would mint; the lookup that
|
|
140
139
|
// reads it back on the next load happens either way.
|
|
141
|
-
const { submitForm, run, pending, error, wake, cancel } = useWorkflowSubmit<
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
);
|
|
140
|
+
const { submitForm, run, pending, error, wake, cancel, startedHere } = useWorkflowSubmit<
|
|
141
|
+
typeof dailyDigest
|
|
142
|
+
>(WORKFLOW, { key });
|
|
145
143
|
|
|
146
144
|
return (
|
|
147
145
|
<main className="mx-auto flex max-w-2xl flex-col gap-6 p-8">
|
|
@@ -149,13 +147,7 @@ export function App() {
|
|
|
149
147
|
|
|
150
148
|
{/* `submit()` resolves as soon as the run EXISTS — deliberately not when it
|
|
151
149
|
finishes, which here could be a month away. */}
|
|
152
|
-
<Form
|
|
153
|
-
onSubmit={(values) => {
|
|
154
|
-
setStartedHere(true);
|
|
155
|
-
return submitForm(values);
|
|
156
|
-
}}
|
|
157
|
-
error={error}
|
|
158
|
-
>
|
|
150
|
+
<Form onSubmit={(values) => submitForm(values)} error={error}>
|
|
159
151
|
<WorkflowFields workflow={WORKFLOW} />
|
|
160
152
|
<SubmitButton pending={pending}>
|
|
161
153
|
{pending ? "Digest scheduled" : "Start digest"}
|
|
@@ -195,7 +187,13 @@ export function App() {
|
|
|
195
187
|
</div>
|
|
196
188
|
)}
|
|
197
189
|
|
|
198
|
-
{
|
|
190
|
+
{/* `role="alert"`, the same contract `<Form>` gives the submit error
|
|
191
|
+
above: a digest that fails does so days later, with nobody watching. */}
|
|
192
|
+
{run?.status === "failed" && (
|
|
193
|
+
<p role="alert" className="text-red-600">
|
|
194
|
+
That run failed: {run.error}
|
|
195
|
+
</p>
|
|
196
|
+
)}
|
|
199
197
|
{run?.status === "cancelled" && <p>Cancelled — no further digests will be posted.</p>}
|
|
200
198
|
|
|
201
199
|
{run?.status === "completed" && (
|
|
@@ -213,11 +211,7 @@ export function App() {
|
|
|
213
211
|
</a>
|
|
214
212
|
</h2>
|
|
215
213
|
<p>{episode.summary}</p>
|
|
216
|
-
<
|
|
217
|
-
{episode.keyPoints.map((point) => (
|
|
218
|
-
<li key={point}>{point}</li>
|
|
219
|
-
))}
|
|
220
|
-
</ul>
|
|
214
|
+
<BulletList items={episode.keyPoints} size="sm" />
|
|
221
215
|
</section>
|
|
222
216
|
))}
|
|
223
217
|
</article>
|
|
@@ -226,4 +220,4 @@ export function App() {
|
|
|
226
220
|
);
|
|
227
221
|
}
|
|
228
222
|
|
|
229
|
-
|
|
223
|
+
mountPage({ name: "Podcast Digest", component: App });
|
|
@@ -49,13 +49,13 @@
|
|
|
49
49
|
* @module digest
|
|
50
50
|
*/
|
|
51
51
|
|
|
52
|
-
import type {
|
|
53
|
-
import { mapConcurrent,
|
|
52
|
+
import type { WorkflowContext } from "@alexkroman1/aai";
|
|
53
|
+
import { mapConcurrent, stepReport, TRANSCRIBE_API } from "@alexkroman1/aai/step";
|
|
54
54
|
import {
|
|
55
55
|
FatalError,
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
stepGenerateJsonOrFail,
|
|
57
|
+
stepTranscribePollOrFail,
|
|
58
|
+
stepTranscribeSubmitOrFail,
|
|
59
59
|
} from "@alexkroman1/aai/step-errors";
|
|
60
60
|
import { errorMessage } from "@alexkroman1/aai/utils";
|
|
61
61
|
import type { WorkflowInputOf } from "@alexkroman1/aai/workflow-api";
|
|
@@ -160,7 +160,7 @@ export type DailyDigestOutput = {
|
|
|
160
160
|
} | null;
|
|
161
161
|
};
|
|
162
162
|
|
|
163
|
-
/** What the model must answer with, and what `
|
|
163
|
+
/** What the model must answer with, and what `stepGenerateJsonOrFail` enforces. */
|
|
164
164
|
const SummaryReply = z.object({
|
|
165
165
|
summary: z.string().trim().min(1),
|
|
166
166
|
keyPoints: z.array(z.string().trim().min(1)).min(1).max(5),
|
|
@@ -178,7 +178,7 @@ const SummaryReply = z.object({
|
|
|
178
178
|
*/
|
|
179
179
|
export async function dailyDigestFlow(
|
|
180
180
|
input: DigestInput,
|
|
181
|
-
ctx:
|
|
181
|
+
ctx: WorkflowContext,
|
|
182
182
|
): Promise<DailyDigestOutput> {
|
|
183
183
|
// No `??` fallbacks: {@link DigestInput} is the schema's OUTPUT, so every
|
|
184
184
|
// `.default()` has already run by the time a run reaches this line. The
|
|
@@ -269,7 +269,7 @@ export async function dailyDigestFlow(
|
|
|
269
269
|
*/
|
|
270
270
|
async function waitForTranscripts(
|
|
271
271
|
jobs: TranscriptJob[],
|
|
272
|
-
ctx:
|
|
272
|
+
ctx: WorkflowContext,
|
|
273
273
|
): Promise<TranscriptState[]> {
|
|
274
274
|
let pending = jobs;
|
|
275
275
|
// Keyed by episode id rather than appended, and that is what keeps the digest
|
|
@@ -287,17 +287,19 @@ async function waitForTranscripts(
|
|
|
287
287
|
if (pending.length > 0) await ctx.sleep("poll", POLL_DELAY_MS);
|
|
288
288
|
}
|
|
289
289
|
|
|
290
|
-
for (const job of pending) settled.set(job.id, gaveUpOn(job));
|
|
291
290
|
// The list `jobs` arrived in, which `discoverEpisodes` sorted newest first.
|
|
291
|
+
// A job still pending is absent from `settled`, so the `??` IS the
|
|
292
|
+
// give-up path — `pollTranscript` returns the job unchanged while it is
|
|
293
|
+
// submitted, so the value given up on is the one that went in.
|
|
292
294
|
return jobs.map((job) => settled.get(job.id) ?? gaveUpOn(job));
|
|
293
295
|
}
|
|
294
296
|
|
|
295
297
|
/**
|
|
296
298
|
* One episode the run is done waiting for.
|
|
297
299
|
*
|
|
298
|
-
* Its own function because {@link waitForTranscripts}
|
|
299
|
-
*
|
|
300
|
-
*
|
|
300
|
+
* Its own function because {@link waitForTranscripts} reaches it from the
|
|
301
|
+
* ordered rebuild, for every job whose budget ran out while it was still
|
|
302
|
+
* submitted and which is therefore absent from `settled`.
|
|
301
303
|
*/
|
|
302
304
|
function gaveUpOn(job: TranscriptJob): TranscriptState {
|
|
303
305
|
return {
|
|
@@ -322,7 +324,7 @@ function gaveUpOn(job: TranscriptJob): TranscriptState {
|
|
|
322
324
|
* an `unavailable` VALUE rather than a throw, because one bad episode must not
|
|
323
325
|
* sink a digest of five.
|
|
324
326
|
*
|
|
325
|
-
* The verdict itself is the SDK's: `
|
|
327
|
+
* The verdict itself is the SDK's: `stepTranscribeSubmitOrFail` reads
|
|
326
328
|
* `TranscribeError`'s own `retryable` AND its `retryAfter`, and throws a
|
|
327
329
|
* `FatalError` or a `RetryableError` accordingly. The hand-written
|
|
328
330
|
* `err instanceof TranscribeError && err.retryable` this replaces read only the
|
|
@@ -330,9 +332,9 @@ function gaveUpOn(job: TranscriptJob): TranscriptState {
|
|
|
330
332
|
* one-second default instead.
|
|
331
333
|
*/
|
|
332
334
|
export async function submitTranscript(episode: Episode): Promise<TranscriptJob> {
|
|
333
|
-
await
|
|
335
|
+
await stepReport(`Submitting ${episode.title} for transcription.`);
|
|
334
336
|
try {
|
|
335
|
-
const { id } = await
|
|
337
|
+
const { id } = await stepTranscribeSubmitOrFail(episode.audioUrl, {
|
|
336
338
|
// A digest quotes nobody, so who spoke costs time for nothing.
|
|
337
339
|
params: { speaker_labels: false },
|
|
338
340
|
});
|
|
@@ -356,12 +358,12 @@ export async function pollTranscript(job: TranscriptJob): Promise<TranscriptStat
|
|
|
356
358
|
if (job.transcriptStatus === "unavailable") return job;
|
|
357
359
|
|
|
358
360
|
try {
|
|
359
|
-
const progress = await
|
|
361
|
+
const progress = await stepTranscribePollOrFail(job.transcriptId);
|
|
360
362
|
// Branch on `done`, never on a status string: a vocabulary this body does
|
|
361
363
|
// not own would otherwise read as "not finished yet" forever.
|
|
362
364
|
if (!progress.done) return job;
|
|
363
365
|
|
|
364
|
-
await
|
|
366
|
+
await stepReport(`Transcribed ${job.title}.`);
|
|
365
367
|
return {
|
|
366
368
|
...job,
|
|
367
369
|
transcriptStatus: "done",
|
|
@@ -391,8 +393,8 @@ export async function summarizeTranscript(state: TranscriptState): Promise<Episo
|
|
|
391
393
|
};
|
|
392
394
|
}
|
|
393
395
|
|
|
394
|
-
await
|
|
395
|
-
const parsed = await
|
|
396
|
+
await stepReport(`Summarizing ${state.title}.`);
|
|
397
|
+
const parsed = await stepGenerateJsonOrFail(
|
|
396
398
|
[
|
|
397
399
|
`Podcast: ${state.podcastTitle}`,
|
|
398
400
|
`Episode: ${state.title}`,
|
|
@@ -64,9 +64,9 @@
|
|
|
64
64
|
* parsing. It is the section below's subject, not this one's.
|
|
65
65
|
*/
|
|
66
66
|
|
|
67
|
-
import { type FeedItem, pageMetadata, parseFeed } from "@alexkroman1/aai/html";
|
|
68
|
-
import {
|
|
69
|
-
import { FatalError,
|
|
67
|
+
import { type FeedItem, type ParsedFeed, pageMetadata, parseFeed } from "@alexkroman1/aai/html";
|
|
68
|
+
import { mapConcurrent, stepReport } from "@alexkroman1/aai/step";
|
|
69
|
+
import { FatalError, stepFetchOrFail } from "@alexkroman1/aai/step-errors";
|
|
70
70
|
import { isRecord, omitUndefined, safeJsonParse } from "@alexkroman1/aai/utils";
|
|
71
71
|
import { z } from "zod";
|
|
72
72
|
|
|
@@ -74,19 +74,32 @@ import { z } from "zod";
|
|
|
74
74
|
const REQUEST_TIMEOUT_MS = 30_000;
|
|
75
75
|
|
|
76
76
|
/**
|
|
77
|
-
*
|
|
78
|
-
* resolving it already had to download one.
|
|
77
|
+
* How many pasted links are resolved at once.
|
|
79
78
|
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
|
|
79
|
+
* Small because the far side is Apple and Spotify, who rate-limit, and because
|
|
80
|
+
* a digest is a handful of shows rather than a fan-out — the win is turning N
|
|
81
|
+
* sequential round trips into a couple of overlapped ones, not saturating a
|
|
82
|
+
* link.
|
|
83
|
+
*/
|
|
84
|
+
const RESOLVE_CONCURRENCY = 4;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A show, reduced to what the rest of the run needs — plus the PARSE, when
|
|
88
|
+
* resolving it already had to do one.
|
|
89
|
+
*
|
|
90
|
+
* `parsed` is the difference between one request per feed and two, and between
|
|
91
|
+
* one parse and three. Both web paths fetch the feed to decide whether it IS a
|
|
92
|
+
* feed, and without somewhere to put the result the reader downloads and
|
|
93
|
+
* re-parses the identical document. Re-parsing is the more expensive half:
|
|
94
|
+
* `parseFeed` builds a DOM and runs `htmlToText` over every item's title AND
|
|
95
|
+
* description, and a podcast feed routinely carries 300+ entries of HTML show
|
|
96
|
+
* notes. The Apple and Spotify paths resolve a URL without ever reading the
|
|
97
|
+
* feed, so they leave it unset and the reader fetches once.
|
|
85
98
|
*/
|
|
86
99
|
export type PodcastFeed = {
|
|
87
100
|
feedUrl: string;
|
|
88
101
|
title: string;
|
|
89
|
-
|
|
102
|
+
parsed?: ParsedFeed;
|
|
90
103
|
};
|
|
91
104
|
|
|
92
105
|
/** One episode with audio attached — the unit everything downstream works on. */
|
|
@@ -114,12 +127,17 @@ export async function discoverEpisodes(
|
|
|
114
127
|
podcastChannels: string,
|
|
115
128
|
maxEpisodes: number,
|
|
116
129
|
): Promise<Episode[]> {
|
|
117
|
-
await
|
|
130
|
+
await stepReport("Finding recent podcast episodes.");
|
|
118
131
|
const links = parsePodcastChannels(podcastChannels);
|
|
119
132
|
if (links.length === 0) throw new FatalError("Add at least one podcast link.");
|
|
120
133
|
|
|
121
|
-
|
|
122
|
-
|
|
134
|
+
// Overlapped, not sequential: each resolution is one to three HTTP round
|
|
135
|
+
// trips with a 30s ceiling, and the links are independent. Bounded rather
|
|
136
|
+
// than a bare `Promise.all` because Apple and Spotify both rate-limit — and
|
|
137
|
+
// legal here because this whole function is a STEP BODY (`ctx.step` wraps it
|
|
138
|
+
// in `digest.ts`), so the journal's name+occurrence rule does not apply to
|
|
139
|
+
// what happens inside it.
|
|
140
|
+
const feeds = await mapConcurrent(links, RESOLVE_CONCURRENCY, resolvePodcastFeed);
|
|
123
141
|
|
|
124
142
|
const episodes = (await Promise.all(feeds.map((feed) => readPodcastFeed(feed))))
|
|
125
143
|
.flat()
|
|
@@ -154,7 +172,10 @@ async function resolvePodcastFeed(url: string): Promise<PodcastFeed> {
|
|
|
154
172
|
*/
|
|
155
173
|
async function resolveWebPodcastFeed(url: string): Promise<PodcastFeed> {
|
|
156
174
|
const body = await fetchText(url);
|
|
157
|
-
|
|
175
|
+
// Parsed ONCE and carried: the "is this a feed" test, the channel title and
|
|
176
|
+
// the item list are three reads of one parse, not three parses.
|
|
177
|
+
const direct = parseFeed(body);
|
|
178
|
+
if (carriesAudio(direct)) return feedFrom(url, direct);
|
|
158
179
|
|
|
159
180
|
const discovered = discoverFeedUrl(body, url);
|
|
160
181
|
if (!discovered) {
|
|
@@ -167,9 +188,9 @@ async function resolveWebPodcastFeed(url: string): Promise<PodcastFeed> {
|
|
|
167
188
|
// The advertised URL is verified rather than trusted: plenty of pages point
|
|
168
189
|
// `application/rss+xml` at a blog feed with no audio in it, and finding that
|
|
169
190
|
// out here names the page, where finding it out later names an empty digest.
|
|
170
|
-
// The
|
|
171
|
-
const verified = await fetchText(discovered);
|
|
172
|
-
if (!
|
|
191
|
+
// The parse is carried forward rather than redone — see `PodcastFeed.parsed`.
|
|
192
|
+
const verified = parseFeed(await fetchText(discovered));
|
|
193
|
+
if (!carriesAudio(verified)) {
|
|
173
194
|
throw new FatalError(`The feed at ${discovered} does not look like a podcast RSS feed.`);
|
|
174
195
|
}
|
|
175
196
|
return feedFrom(discovered, verified);
|
|
@@ -236,8 +257,9 @@ async function resolveSpotifyPodcastFeed(url: string): Promise<PodcastFeed> {
|
|
|
236
257
|
|
|
237
258
|
/** Every item in the feed that has audio attached, newest first by the caller. */
|
|
238
259
|
async function readPodcastFeed(feed: PodcastFeed): Promise<Episode[]> {
|
|
239
|
-
|
|
240
|
-
|
|
260
|
+
// Already parsed on the web paths; the Apple and Spotify paths resolve a URL
|
|
261
|
+
// without ever reading the feed, so this is where those two download it.
|
|
262
|
+
const parsed = feed.parsed ?? parseFeed(await fetchText(feed.feedUrl));
|
|
241
263
|
// `parsed.title` is the CHANNEL's, which is the whole reason to parse: the
|
|
242
264
|
// `indexOf` read this replaces took the first `<title>` at any depth.
|
|
243
265
|
const podcastTitle = parsed?.title ?? feed.title;
|
|
@@ -393,17 +415,23 @@ export function titleMatchesSpotify(
|
|
|
393
415
|
);
|
|
394
416
|
}
|
|
395
417
|
|
|
396
|
-
/**
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
418
|
+
/**
|
|
419
|
+
* A parse that actually carries audio — both halves are required.
|
|
420
|
+
*
|
|
421
|
+
* `parseFeed` answering at all is the "is this a feed" half, and it is stricter
|
|
422
|
+
* than the `<rss` test it replaces in the direction that matters: an HTML page
|
|
423
|
+
* mentioning `<rss` in prose is not a feed. It is also wider where being wide is
|
|
424
|
+
* right — an Atom podcast feed has no `<rss` root and was refused outright.
|
|
425
|
+
*/
|
|
426
|
+
function carriesAudio(parsed: ParsedFeed | undefined): parsed is ParsedFeed {
|
|
404
427
|
return parsed?.items.some((item) => item.enclosureUrl !== undefined) ?? false;
|
|
405
428
|
}
|
|
406
429
|
|
|
430
|
+
/** {@link carriesAudio} over a document that has not been parsed yet. */
|
|
431
|
+
export function looksLikePodcastFeed(xml: string): boolean {
|
|
432
|
+
return carriesAudio(parseFeed(xml));
|
|
433
|
+
}
|
|
434
|
+
|
|
407
435
|
/** The first feed a page advertises, resolved against the page's own URL. */
|
|
408
436
|
export function discoverFeedUrl(html: string, pageUrl: string): string | undefined {
|
|
409
437
|
// `pageMetadata` reads `<link>` off a parse, so attribute ORDER stops
|
|
@@ -441,11 +469,11 @@ function publishedAt(episode: Episode): number {
|
|
|
441
469
|
return Number.isNaN(parsed) ? 0 : parsed;
|
|
442
470
|
}
|
|
443
471
|
|
|
444
|
-
function feedFrom(feedUrl: string,
|
|
472
|
+
function feedFrom(feedUrl: string, parsed: ParsedFeed): PodcastFeed {
|
|
445
473
|
return {
|
|
446
474
|
feedUrl,
|
|
447
|
-
title:
|
|
448
|
-
|
|
475
|
+
title: parsed.title ?? hostOf(feedUrl),
|
|
476
|
+
parsed,
|
|
449
477
|
};
|
|
450
478
|
}
|
|
451
479
|
|
|
@@ -529,7 +557,7 @@ function appleResults(body: unknown): AppleResult[] {
|
|
|
529
557
|
/**
|
|
530
558
|
* Every outbound call in this file, with one timeout and one failure policy.
|
|
531
559
|
*
|
|
532
|
-
* `
|
|
560
|
+
* `stepFetchOrFail` is `stepFetch` plus the non-2xx branch — see its own doc. Three
|
|
533
561
|
* things come with it that a bare `fetch` here would each have to re-derive:
|
|
534
562
|
* HTTP/1.1 (so a rate limit arrives as a `503` with `Retry-After` rather than
|
|
535
563
|
* an h2 stream reset carrying no status), the transient/terminal verdict the
|
|
@@ -537,7 +565,7 @@ function appleResults(body: unknown): AppleResult[] {
|
|
|
537
565
|
* the iTunes endpoints is the difference between "403" and a sentence.
|
|
538
566
|
*/
|
|
539
567
|
async function fetchText(url: string): Promise<string> {
|
|
540
|
-
const response = await
|
|
568
|
+
const response = await stepFetchOrFail(url, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
|
|
541
569
|
return await response.text();
|
|
542
570
|
}
|
|
543
571
|
|