@alexkroman1/aai-cli 6.8.0 → 6.10.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/cli.mjs +84 -56
- package/dist/logs-BWVGSjLR.mjs +74 -0
- package/dist/logs.d.ts +63 -0
- package/dist/scaffold/CLAUDE.md +30 -10
- package/dist/scaffold/package.json +3 -3
- package/dist/templates/dispatch-center/agent.ts +2 -2
- package/dist/templates/dispatch-center/client.tsx +3 -7
- package/dist/templates/dispatch-center/shared.ts +3 -0
- package/dist/templates/link-digest/agent.ts +5 -2
- package/dist/templates/pizza-ordering/agent.test.ts +10 -3
- package/dist/templates/pizza-ordering/agent.ts +2 -2
- package/dist/templates/pizza-ordering/client.tsx +2 -7
- package/dist/templates/pizza-ordering/shared.ts +3 -0
- package/dist/templates/plan-and-execute/agent.test.ts +4 -3
- package/dist/templates/plan-and-execute/agent.ts +2 -2
- package/dist/templates/plan-and-execute/client.tsx +2 -5
- package/dist/templates/plan-and-execute/shared.ts +3 -0
- package/dist/templates/recap-workflow/agent.ts +5 -2
- package/dist/templates/research-workflow/agent.ts +5 -2
- package/dist/templates/retail/client.tsx +10 -4
- package/dist/templates/solo-rpg/agent.ts +2 -2
- package/dist/templates/solo-rpg/client.tsx +5 -14
- package/dist/templates/solo-rpg/shared.ts +7 -0
- package/dist/templates/support-line/agent.test.ts +4 -3
- package/dist/templates/support-line/agent.ts +2 -2
- package/dist/templates/support-line/client.tsx +2 -5
- package/dist/templates/support-line/shared.ts +3 -0
- package/dist/templates/transcription-workflow/agent.ts +4 -2
- package/dist/templates/transcription-workflow/client.tsx +0 -5
- package/dist/templates/travel-concierge/agent.test.ts +4 -3
- package/dist/templates/travel-concierge/agent.ts +2 -2
- package/dist/templates/travel-concierge/client.tsx +2 -6
- package/dist/templates/travel-concierge/shared.ts +3 -0
- package/package.json +3 -3
- package/dist/templates/transcription-workflow/api-help.tsx +0 -214
package/dist/cli.mjs
CHANGED
|
@@ -760,68 +760,95 @@ const storageDir = {
|
|
|
760
760
|
description: "Project directory",
|
|
761
761
|
required: false
|
|
762
762
|
};
|
|
763
|
+
const storageStatus = defineExec({
|
|
764
|
+
meta: {
|
|
765
|
+
name: "status",
|
|
766
|
+
description: "Show whether storage is enabled"
|
|
767
|
+
},
|
|
768
|
+
args: {
|
|
769
|
+
dir: storageDir,
|
|
770
|
+
server: sharedArgs.server,
|
|
771
|
+
json: sharedArgs.json
|
|
772
|
+
},
|
|
773
|
+
cwd: "any",
|
|
774
|
+
async run({ args, cwd }) {
|
|
775
|
+
const { executeStorageStatus } = await import("./storage-DzRZ-eCw.mjs");
|
|
776
|
+
return executeStorageStatus(resolveStorageCwd(cwd, args.dir), args.server);
|
|
777
|
+
}
|
|
778
|
+
});
|
|
779
|
+
const storageEnable = defineExec({
|
|
780
|
+
meta: {
|
|
781
|
+
name: "enable",
|
|
782
|
+
description: "Enable the agent's app database"
|
|
783
|
+
},
|
|
784
|
+
args: {
|
|
785
|
+
dir: storageDir,
|
|
786
|
+
server: sharedArgs.server,
|
|
787
|
+
json: sharedArgs.json
|
|
788
|
+
},
|
|
789
|
+
cwd: "any",
|
|
790
|
+
async run({ args, cwd }) {
|
|
791
|
+
const { executeStorageEnable } = await import("./storage-DzRZ-eCw.mjs");
|
|
792
|
+
return executeStorageEnable(resolveStorageCwd(cwd, args.dir), args.server);
|
|
793
|
+
}
|
|
794
|
+
});
|
|
795
|
+
const storageDisable = defineExec({
|
|
796
|
+
meta: {
|
|
797
|
+
name: "disable",
|
|
798
|
+
description: "Disable storage and DROP the database schema with all its data"
|
|
799
|
+
},
|
|
800
|
+
args: {
|
|
801
|
+
dir: storageDir,
|
|
802
|
+
force: {
|
|
803
|
+
type: "boolean",
|
|
804
|
+
alias: "f",
|
|
805
|
+
description: "Skip confirmation prompt"
|
|
806
|
+
},
|
|
807
|
+
server: sharedArgs.server,
|
|
808
|
+
json: sharedArgs.json
|
|
809
|
+
},
|
|
810
|
+
cwd: "any",
|
|
811
|
+
async run({ args, cwd }) {
|
|
812
|
+
const { executeStorageDisable } = await import("./storage-DzRZ-eCw.mjs");
|
|
813
|
+
return executeStorageDisable(resolveStorageCwd(cwd, args.dir), {
|
|
814
|
+
server: args.server,
|
|
815
|
+
force: args.force
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
});
|
|
819
|
+
const logs = defineExec({
|
|
820
|
+
meta: {
|
|
821
|
+
name: "logs",
|
|
822
|
+
description: "Show what the deployed agent has printed"
|
|
823
|
+
},
|
|
824
|
+
args: {
|
|
825
|
+
dir: storageDir,
|
|
826
|
+
follow: {
|
|
827
|
+
type: "boolean",
|
|
828
|
+
alias: "f",
|
|
829
|
+
description: "Keep printing new output"
|
|
830
|
+
},
|
|
831
|
+
server: sharedArgs.server,
|
|
832
|
+
json: sharedArgs.json
|
|
833
|
+
},
|
|
834
|
+
cwd: "any",
|
|
835
|
+
async run({ args, cwd }) {
|
|
836
|
+
const { executeLogs } = await import("./logs-BWVGSjLR.mjs");
|
|
837
|
+
return executeLogs(resolveStorageCwd(cwd, args.dir), {
|
|
838
|
+
server: args.server,
|
|
839
|
+
follow: args.follow
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
});
|
|
763
843
|
const storage = defineCommand({
|
|
764
844
|
meta: {
|
|
765
845
|
name: "storage",
|
|
766
846
|
description: "Manage the agent's app database"
|
|
767
847
|
},
|
|
768
848
|
subCommands: {
|
|
769
|
-
status:
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
description: "Show whether storage is enabled"
|
|
773
|
-
},
|
|
774
|
-
args: {
|
|
775
|
-
dir: storageDir,
|
|
776
|
-
server: sharedArgs.server,
|
|
777
|
-
json: sharedArgs.json
|
|
778
|
-
},
|
|
779
|
-
cwd: "any",
|
|
780
|
-
async run({ args, cwd }) {
|
|
781
|
-
const { executeStorageStatus } = await import("./storage-DzRZ-eCw.mjs");
|
|
782
|
-
return executeStorageStatus(resolveStorageCwd(cwd, args.dir), args.server);
|
|
783
|
-
}
|
|
784
|
-
}),
|
|
785
|
-
enable: defineExec({
|
|
786
|
-
meta: {
|
|
787
|
-
name: "enable",
|
|
788
|
-
description: "Enable the agent's app database"
|
|
789
|
-
},
|
|
790
|
-
args: {
|
|
791
|
-
dir: storageDir,
|
|
792
|
-
server: sharedArgs.server,
|
|
793
|
-
json: sharedArgs.json
|
|
794
|
-
},
|
|
795
|
-
cwd: "any",
|
|
796
|
-
async run({ args, cwd }) {
|
|
797
|
-
const { executeStorageEnable } = await import("./storage-DzRZ-eCw.mjs");
|
|
798
|
-
return executeStorageEnable(resolveStorageCwd(cwd, args.dir), args.server);
|
|
799
|
-
}
|
|
800
|
-
}),
|
|
801
|
-
disable: defineExec({
|
|
802
|
-
meta: {
|
|
803
|
-
name: "disable",
|
|
804
|
-
description: "Disable storage and DROP the database schema with all its data"
|
|
805
|
-
},
|
|
806
|
-
args: {
|
|
807
|
-
dir: storageDir,
|
|
808
|
-
force: {
|
|
809
|
-
type: "boolean",
|
|
810
|
-
alias: "f",
|
|
811
|
-
description: "Skip confirmation prompt"
|
|
812
|
-
},
|
|
813
|
-
server: sharedArgs.server,
|
|
814
|
-
json: sharedArgs.json
|
|
815
|
-
},
|
|
816
|
-
cwd: "any",
|
|
817
|
-
async run({ args, cwd }) {
|
|
818
|
-
const { executeStorageDisable } = await import("./storage-DzRZ-eCw.mjs");
|
|
819
|
-
return executeStorageDisable(resolveStorageCwd(cwd, args.dir), {
|
|
820
|
-
server: args.server,
|
|
821
|
-
force: args.force
|
|
822
|
-
});
|
|
823
|
-
}
|
|
824
|
-
})
|
|
849
|
+
status: storageStatus,
|
|
850
|
+
enable: storageEnable,
|
|
851
|
+
disable: storageDisable
|
|
825
852
|
}
|
|
826
853
|
});
|
|
827
854
|
const mainCommand = defineCommand({
|
|
@@ -854,6 +881,7 @@ const mainCommand = defineCommand({
|
|
|
854
881
|
}
|
|
855
882
|
}),
|
|
856
883
|
secret,
|
|
884
|
+
logs,
|
|
857
885
|
storage,
|
|
858
886
|
workflow,
|
|
859
887
|
templates: defineExec({
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { a as ok } from "./_output-CKkmWs7i.mjs";
|
|
3
|
+
import { n as log } from "./_ui-u7T4YooX.mjs";
|
|
4
|
+
import { f as checkedResponse } from "./_studio-U3jhKlA3.mjs";
|
|
5
|
+
import { n as slugRequest } from "./_slug-api-BxrsJXVI.mjs";
|
|
6
|
+
import { isRecord, omitUndefined } from "@alexkroman1/aai/utils";
|
|
7
|
+
import { sleep } from "@alexkroman1/aai/internal";
|
|
8
|
+
function isLogsPage(value) {
|
|
9
|
+
return isRecord(value) && Array.isArray(value.lines) && typeof value.cursor === "number" && typeof value.running === "boolean";
|
|
10
|
+
}
|
|
11
|
+
/** `HH:MM:SS.mmm`, local — the same shape the studio pane prints. */
|
|
12
|
+
function formatTime(at) {
|
|
13
|
+
const d = new Date(at);
|
|
14
|
+
const pad = (n, width = 2) => String(n).padStart(width, "0");
|
|
15
|
+
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`;
|
|
16
|
+
}
|
|
17
|
+
/** One rendered line: time, then the text. `stderr` is called out by name. */
|
|
18
|
+
function formatLine(line) {
|
|
19
|
+
const mark = line.stream === "stderr" ? " ERR" : "";
|
|
20
|
+
return `${formatTime(line.at)}${mark} ${line.text}`;
|
|
21
|
+
}
|
|
22
|
+
async function readPage(cwd, after, server) {
|
|
23
|
+
const { data, slug } = await slugRequest(cwd, `/logs?after=${after}`, { action: "logs" }, server);
|
|
24
|
+
return {
|
|
25
|
+
page: checkedResponse(data, isLogsPage, `the logs route for ${slug}`),
|
|
26
|
+
slug
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Print the agent's buffered output, once or continuously.
|
|
31
|
+
*
|
|
32
|
+
* The `--json` shape reports the LINE COUNT rather than the lines: a follow
|
|
33
|
+
* loop has no final count to report, and a one-shot read that dumped every line
|
|
34
|
+
* into a result object would duplicate what it just printed.
|
|
35
|
+
*/
|
|
36
|
+
async function executeLogs(cwd, opts = {}) {
|
|
37
|
+
const { page, slug } = await readPage(cwd, -1, opts.server);
|
|
38
|
+
let cursor = page.cursor;
|
|
39
|
+
let printed = printPage(page);
|
|
40
|
+
if (!opts.follow) {
|
|
41
|
+
if (printed === 0) log.info(page.running ? `${slug} is running and has printed nothing yet.` : `${slug} isn't running. Start a session, or send it a request, and its output shows up here.`);
|
|
42
|
+
log.info("Recent output only — an agent's log lives in its sandbox and goes when it does.");
|
|
43
|
+
return ok({
|
|
44
|
+
slug,
|
|
45
|
+
lines: printed,
|
|
46
|
+
running: page.running
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
log.info(`Following ${slug}. Ctrl-C to stop.`);
|
|
50
|
+
const pollMs = opts.pollMs ?? 1e3;
|
|
51
|
+
let running = page.running;
|
|
52
|
+
while (!opts.signal?.aborted) {
|
|
53
|
+
await sleep(pollMs, omitUndefined({ signal: opts.signal }));
|
|
54
|
+
if (opts.signal?.aborted) break;
|
|
55
|
+
const next = await readPage(cwd, cursor, opts.server).catch(() => void 0);
|
|
56
|
+
if (!next) continue;
|
|
57
|
+
cursor = next.page.cursor;
|
|
58
|
+
running = next.page.running;
|
|
59
|
+
printed += printPage(next.page);
|
|
60
|
+
}
|
|
61
|
+
return ok({
|
|
62
|
+
slug,
|
|
63
|
+
lines: printed,
|
|
64
|
+
running
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
/** Write a page's lines (and any gap) to the log. Returns how many lines. */
|
|
68
|
+
function printPage(page) {
|
|
69
|
+
if (page.dropped > 0) log.warn(`… ${page.dropped} earlier line(s) dropped — the agent printed faster than this read`);
|
|
70
|
+
for (const line of page.lines) log.message(formatLine(line));
|
|
71
|
+
return page.lines.length;
|
|
72
|
+
}
|
|
73
|
+
//#endregion
|
|
74
|
+
export { executeLogs };
|
package/dist/logs.d.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `aai logs` — what the deployed agent has printed.
|
|
3
|
+
*
|
|
4
|
+
* The terminal half of the same read the studio's Logs pane makes
|
|
5
|
+
* (`GET /:slug/logs`, aai-server/agent-logs.ts): a bounded ring the GUEST holds,
|
|
6
|
+
* paged by cursor. Two things follow from that and are what this command has to
|
|
7
|
+
* be honest about, because neither is visible in a wall of lines:
|
|
8
|
+
*
|
|
9
|
+
* - **The ring dies with the sandbox.** This is "what my agent printed
|
|
10
|
+
* recently", never "what it printed last Tuesday", and the footer says so on
|
|
11
|
+
* a one-shot read rather than leaving the absence to be inferred.
|
|
12
|
+
* - **`running` is not `lines.length`.** An agent that is up and quiet and one
|
|
13
|
+
* that is not up at all both print nothing, and they want opposite things
|
|
14
|
+
* from the reader. The one-shot read reports which it was.
|
|
15
|
+
*
|
|
16
|
+
* `--follow` polls. A stream would be the nicer shape and the source is not one:
|
|
17
|
+
* the guest holds a RING with a cursor, and a reconnecting stream would have to
|
|
18
|
+
* re-derive that cursor anyway.
|
|
19
|
+
*/
|
|
20
|
+
import { type CommandResult } from "./_output.ts";
|
|
21
|
+
/** One captured line, as the platform reports it. */
|
|
22
|
+
export type LogLine = {
|
|
23
|
+
seq: number;
|
|
24
|
+
at: number;
|
|
25
|
+
stream: "stdout" | "stderr";
|
|
26
|
+
text: string;
|
|
27
|
+
};
|
|
28
|
+
/** One read of the ring. Mirrors `AgentLogsResponse` on the platform. */
|
|
29
|
+
export type LogsPage = {
|
|
30
|
+
lines: LogLine[];
|
|
31
|
+
cursor: number;
|
|
32
|
+
dropped: number;
|
|
33
|
+
running: boolean;
|
|
34
|
+
};
|
|
35
|
+
/** How often `--follow` asks for what is new. */
|
|
36
|
+
export declare const FOLLOW_POLL_MS = 1000;
|
|
37
|
+
export type LogsOpts = {
|
|
38
|
+
server?: string | undefined;
|
|
39
|
+
/** Keep polling until interrupted. */
|
|
40
|
+
follow?: boolean | undefined;
|
|
41
|
+
/** Poll cadence override, for tests. */
|
|
42
|
+
pollMs?: number | undefined;
|
|
43
|
+
/** Stops a `--follow` loop. Tests pass one; the CLI leaves it to Ctrl-C. */
|
|
44
|
+
signal?: AbortSignal | undefined;
|
|
45
|
+
};
|
|
46
|
+
/** `HH:MM:SS.mmm`, local — the same shape the studio pane prints. */
|
|
47
|
+
export declare function formatTime(at: number): string;
|
|
48
|
+
/** One rendered line: time, then the text. `stderr` is called out by name. */
|
|
49
|
+
export declare function formatLine(line: LogLine): string;
|
|
50
|
+
type LogsData = {
|
|
51
|
+
slug: string;
|
|
52
|
+
lines: number;
|
|
53
|
+
running: boolean;
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Print the agent's buffered output, once or continuously.
|
|
57
|
+
*
|
|
58
|
+
* The `--json` shape reports the LINE COUNT rather than the lines: a follow
|
|
59
|
+
* loop has no final count to report, and a one-shot read that dumped every line
|
|
60
|
+
* into a result object would duplicate what it just printed.
|
|
61
|
+
*/
|
|
62
|
+
export declare function executeLogs(cwd: string, opts?: LogsOpts): Promise<CommandResult<LogsData>>;
|
|
63
|
+
export {};
|
package/dist/scaffold/CLAUDE.md
CHANGED
|
@@ -300,8 +300,17 @@ callback. Reach for `agent()` when someone is on the line — a voice agent can
|
|
|
300
300
|
also START a workflow from a tool (`ctx.workflows.start(def, input)`) and
|
|
301
301
|
answer the turn, which is the other shape.
|
|
302
302
|
|
|
303
|
-
**
|
|
304
|
-
`aai
|
|
303
|
+
**Storage is OPTIONAL, and what it buys is DURABILITY.** With a database
|
|
304
|
+
(`aai storage enable`, Settings → Database in the studio, or `DATABASE_URL`
|
|
305
|
+
under `aai dev`) runs live in it and survive a restart, a redeploy and an idle
|
|
306
|
+
sandbox. Without one they live in the process that started them — you can
|
|
307
|
+
submit the form, watch the run and read its result, and everything in flight is
|
|
308
|
+
lost when that process goes away. That is the honest tradeoff, and it is what
|
|
309
|
+
lets you build a workflow app before provisioning anything.
|
|
310
|
+
|
|
311
|
+
Two things do need the database whatever the run does: `ctx.db`, and a workflow
|
|
312
|
+
**upload** — an upload's record is a row, so `api.upload` and the file-taking
|
|
313
|
+
form hooks refuse by name until storage is on (see "Database API" below).
|
|
305
314
|
|
|
306
315
|
### Workflow bodies live in `workflows/`
|
|
307
316
|
|
|
@@ -1339,16 +1348,26 @@ also what you want for anything that can be a string, an array, or null.
|
|
|
1339
1348
|
// shared.ts — the slot owns the shape; `agent()` has no `state` field.
|
|
1340
1349
|
export const cartSlot = sessionSlot("cart", () => ({ cart: [] as Item[], staffPin: "" }));
|
|
1341
1350
|
|
|
1351
|
+
// Compose the projection HERE, once, and import it at both ends: staffPin stays
|
|
1352
|
+
// server-side, and the agent and the client cannot name different views of it.
|
|
1353
|
+
export const cartProjection = cartSlot.projection((s) => ({ cart: s.cart }));
|
|
1354
|
+
|
|
1342
1355
|
// agent.ts
|
|
1343
|
-
export default agent({
|
|
1344
|
-
syncState: cartSlot.projection((s) => ({ cart: s.cart })), // staffPin stays server-side
|
|
1345
|
-
});
|
|
1356
|
+
export default agent({ syncState: cartProjection });
|
|
1346
1357
|
|
|
1347
|
-
// client.tsx
|
|
1348
|
-
|
|
1349
|
-
|
|
1358
|
+
// client.tsx — the projection types the state AND supplies the frame the client
|
|
1359
|
+
// renders before the first push, so there is no type argument and no `?? EMPTY`.
|
|
1360
|
+
const view = useAgentState(cartProjection);
|
|
1361
|
+
return <Cart items={view.cart} />;
|
|
1350
1362
|
```
|
|
1351
1363
|
|
|
1364
|
+
Passing the projection is the shape to copy. The other two overloads still
|
|
1365
|
+
exist: `useAgentState<S>()` returns `S | null` (nullable — nothing is pushed
|
|
1366
|
+
before the first tool call), and `useAgentState<S>(fallback)` returns `S` for a
|
|
1367
|
+
frame you build yourself. Reach for `fallback` only when the slot's factory is
|
|
1368
|
+
expensive to import into the browser — the projection overload calls it to build
|
|
1369
|
+
the empty frame.
|
|
1370
|
+
|
|
1352
1371
|
**Reach for this before wiring `useToolResult` into `useState`.** Without
|
|
1353
1372
|
it the pattern is: return a cart snapshot from every tool, declare a type
|
|
1354
1373
|
describing what those tools return, and mirror it into `useState` — three
|
|
@@ -1511,8 +1530,9 @@ Common mistakes when working in aai projects:
|
|
|
1511
1530
|
- **`ctx.db` throws until the database is enabled.** Enable it with
|
|
1512
1531
|
`aai storage enable` (CLI), Settings → Database (studio), or
|
|
1513
1532
|
`DATABASE_URL` in `.env` (`aai dev`) before shipping tools that persist
|
|
1514
|
-
data.
|
|
1515
|
-
|
|
1533
|
+
data. A deployed agent reads its connection string when its sandbox is
|
|
1534
|
+
built, so enabling rebuilds it — the change reaches a running agent
|
|
1535
|
+
without a redeploy.
|
|
1516
1536
|
- **The database is per-app.** Rows are shared by every session of one
|
|
1517
1537
|
deployment — key them yourself if sessions must not see each other's data
|
|
1518
1538
|
(or keep session-scoped data in a `sessionSlot`).
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
"publish:agent": "aai publish"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@alexkroman1/aai": "^6.
|
|
17
|
-
"@alexkroman1/aai-ui": "^6.
|
|
16
|
+
"@alexkroman1/aai": "^6.10.0",
|
|
17
|
+
"@alexkroman1/aai-ui": "^6.10.0",
|
|
18
18
|
"@workflow/world-postgres": "4.3.3",
|
|
19
19
|
"react": "^19.2.8",
|
|
20
20
|
"react-dom": "^19.2.8",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"zod": "^4.4.3"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
|
-
"@alexkroman1/aai-cli": "^6.
|
|
26
|
+
"@alexkroman1/aai-cli": "^6.10.0",
|
|
27
27
|
"@tailwindcss/vite": "^4.3.3",
|
|
28
28
|
"@types/node": "^26.2.0",
|
|
29
29
|
"@types/react": "^19.2.18",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { agent } from "@alexkroman1/aai";
|
|
2
|
-
import {
|
|
2
|
+
import { dashboardProjection } from "./shared.ts";
|
|
3
3
|
|
|
4
4
|
export default agent({
|
|
5
5
|
name: "Dispatch Command Center",
|
|
@@ -7,7 +7,7 @@ export default agent({
|
|
|
7
7
|
// something to project rather than an empty state object.
|
|
8
8
|
// One projection replaces eleven `ctx.send("incidents", ...)` calls, and
|
|
9
9
|
// is the single place that decides caller PII stays server-side.
|
|
10
|
-
syncState:
|
|
10
|
+
syncState: dashboardProjection,
|
|
11
11
|
// A dispatcher reads addresses and unit numbers in bursts with pauses inside
|
|
12
12
|
// one message ("unit twelve … respond to"). The default pipeline's
|
|
13
13
|
// `max_turn_silence` already tolerates that; reach for
|
|
@@ -7,8 +7,8 @@ import {
|
|
|
7
7
|
useSession,
|
|
8
8
|
useUserTranscript,
|
|
9
9
|
} from "@alexkroman1/aai-ui";
|
|
10
|
-
import type {
|
|
11
|
-
import {
|
|
10
|
+
import type { DispatchState, IncidentSummary, Severity, Status } from "./shared.ts";
|
|
11
|
+
import { dashboardProjection } from "./shared.ts";
|
|
12
12
|
|
|
13
13
|
const CSS = `
|
|
14
14
|
@keyframes dc-pulse {
|
|
@@ -57,10 +57,6 @@ const statusColors: Record<string, string> = {
|
|
|
57
57
|
escalated: "#ef4444",
|
|
58
58
|
} satisfies Record<Status, string>;
|
|
59
59
|
|
|
60
|
-
// The board before the first tool call — derived from the projection so a
|
|
61
|
-
// new DashboardView field can't silently miss the pre-first-tool-call render.
|
|
62
|
-
const EMPTY_DASH: DashboardView = dispatchSlot.projection(dashboardView)(undefined);
|
|
63
|
-
|
|
64
60
|
// The dot's colour per session state, as an EXHAUSTIVE map rather than an
|
|
65
61
|
// if-chain with a grey default.
|
|
66
62
|
//
|
|
@@ -152,7 +148,7 @@ function App() {
|
|
|
152
148
|
// The agent's own board, projected by `syncState`. This replaced a
|
|
153
149
|
// useState mirror that merged incident deltas out of tool events — the
|
|
154
150
|
// projection is already the complete list, so there is nothing to merge.
|
|
155
|
-
const dash = useAgentState
|
|
151
|
+
const dash = useAgentState(dashboardProjection);
|
|
156
152
|
|
|
157
153
|
const incidentList = [...dash.incidents].reverse();
|
|
158
154
|
const activeIncidents = incidentList.filter((i) => i.status !== "resolved");
|
|
@@ -215,6 +215,9 @@ export const dispatchSlot = sessionSlot("dispatch", createDefaultState, {
|
|
|
215
215
|
},
|
|
216
216
|
});
|
|
217
217
|
|
|
218
|
+
/** The projection BOTH ends use: `syncState` on the agent, `useAgentState` in the client. */
|
|
219
|
+
export const dashboardProjection = dispatchSlot.projection(dashboardView);
|
|
220
|
+
|
|
218
221
|
/**
|
|
219
222
|
* The board as a READ hands it out: deep-frozen, and typed to say so.
|
|
220
223
|
*
|
|
@@ -44,8 +44,11 @@
|
|
|
44
44
|
* doc for the one thing that changes under `aai dev` (the key has to be in
|
|
45
45
|
* `.env`, not just your shell).
|
|
46
46
|
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
47
|
+
* Storage is what makes it DURABLE (`aai storage enable`, Settings → Database
|
|
48
|
+
* in the studio, or `DATABASE_URL` under `aai dev`) — runs and the
|
|
49
|
+
* correlation-key index both live there. Without it both live in the process,
|
|
50
|
+
* so a run in flight is lost when the agent restarts, redeploys or goes idle —
|
|
51
|
+
* which is fine while you are building one.
|
|
49
52
|
*/
|
|
50
53
|
|
|
51
54
|
import { workflow, workflowApp } from "@alexkroman1/aai";
|
|
@@ -18,7 +18,14 @@ const agentDef = withDiscoveredTools(
|
|
|
18
18
|
import.meta.glob("./tools/*.ts", { eager: true }),
|
|
19
19
|
);
|
|
20
20
|
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
calculateTotal,
|
|
23
|
+
orderProjection,
|
|
24
|
+
orderSlot,
|
|
25
|
+
orderView,
|
|
26
|
+
type Pizza,
|
|
27
|
+
pizzaPrice,
|
|
28
|
+
} from "./shared.ts";
|
|
22
29
|
|
|
23
30
|
// ─── Test doubles ────────────────────────────────────────────────────────────
|
|
24
31
|
|
|
@@ -231,8 +238,8 @@ describe("orderView projection", () => {
|
|
|
231
238
|
|
|
232
239
|
test("an untouched session projects an empty cart, not undefined", () => {
|
|
233
240
|
// The client renders before any tool has run, so `state.order` is absent —
|
|
234
|
-
// this is exactly the
|
|
235
|
-
expect(
|
|
241
|
+
// this is exactly the frame `client.tsx` gets from the same projection.
|
|
242
|
+
expect(orderProjection()).toMatchObject({
|
|
236
243
|
pizzas: [],
|
|
237
244
|
total: "$0.00",
|
|
238
245
|
orderPlaced: false,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { agent } from "@alexkroman1/aai";
|
|
2
|
-
import { menuText,
|
|
2
|
+
import { menuText, orderProjection } from "./shared.ts";
|
|
3
3
|
import systemPrompt from "./system-prompt.md?raw";
|
|
4
4
|
|
|
5
5
|
// The in-progress order lives in one `sessionSlot` (see shared.ts) —
|
|
@@ -15,7 +15,7 @@ export default agent({
|
|
|
15
15
|
// The cart, pushed to the client after every tool call. Replaces a
|
|
16
16
|
// `ctx.send("order", ...)` in each of the five order tools, and the
|
|
17
17
|
// event-diffing the client had to do to rebuild the cart from them.
|
|
18
|
-
syncState:
|
|
18
|
+
syncState: orderProjection,
|
|
19
19
|
// The menu section is generated from MENU so the prompt can never quote a
|
|
20
20
|
// price the pricing code doesn't charge.
|
|
21
21
|
systemPrompt: `${systemPrompt}\n${menuText()}`,
|
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
import "@alexkroman1/aai-ui/styles.css";
|
|
2
2
|
import { client, useAgentState, useTheme } from "@alexkroman1/aai-ui";
|
|
3
|
-
import
|
|
4
|
-
import { formatPrice, orderSlot, orderView, pizzaPrice } from "./shared.ts";
|
|
5
|
-
|
|
6
|
-
// Derived from the projection so a new OrderView field can't silently miss
|
|
7
|
-
// the pre-first-tool-call render — `projection` runs it over an empty cart.
|
|
8
|
-
const EMPTY_ORDER: OrderView = orderSlot.projection(orderView)(undefined);
|
|
3
|
+
import { formatPrice, orderProjection, pizzaPrice } from "./shared.ts";
|
|
9
4
|
|
|
10
5
|
function PizzaIcon({ size }: { size: string }) {
|
|
11
6
|
const dim = size === "small" ? 36 : size === "large" ? 52 : 44;
|
|
@@ -30,7 +25,7 @@ function OrderSidebar() {
|
|
|
30
25
|
// tool call. This replaced ~45 lines that rebuilt the cart by diffing
|
|
31
26
|
// added/removed/updated events — where one missed event desynced the view
|
|
32
27
|
// for the rest of the session.
|
|
33
|
-
const order = useAgentState
|
|
28
|
+
const order = useAgentState(orderProjection);
|
|
34
29
|
|
|
35
30
|
if (order.orderPlaced) {
|
|
36
31
|
return (
|
|
@@ -26,7 +26,7 @@ const agentDef = withDiscoveredTools(
|
|
|
26
26
|
import { executeStep, MAX_STEP_SEARCHES, normalizeAct, planNode } from "./graph.ts";
|
|
27
27
|
import { EXECUTOR_SYSTEM, PLANNER_SYSTEM, REPLANNER_SYSTEM, REVISE_SYSTEM } from "./prompts.ts";
|
|
28
28
|
import type { SearchFn } from "./shared.ts";
|
|
29
|
-
import { MAX_PAST_STEPS, planSlot, planView } from "./shared.ts";
|
|
29
|
+
import { MAX_PAST_STEPS, planProjection, planSlot, planView } from "./shared.ts";
|
|
30
30
|
|
|
31
31
|
// ─── A scripted model ────────────────────────────────────────────────────────
|
|
32
32
|
//
|
|
@@ -381,8 +381,9 @@ describe("plan_status", () => {
|
|
|
381
381
|
|
|
382
382
|
describe("planView projection", () => {
|
|
383
383
|
test("an untouched call projects an empty plan, not undefined", () => {
|
|
384
|
-
// Exactly the
|
|
385
|
-
|
|
384
|
+
// Exactly the frame `client.tsx` renders before the first push — it passes
|
|
385
|
+
// this same projection to `useAgentState`.
|
|
386
|
+
expect(planProjection()).toEqual({
|
|
386
387
|
objective: null,
|
|
387
388
|
plan: [],
|
|
388
389
|
done: [],
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { agent } from "@alexkroman1/aai";
|
|
2
|
-
import {
|
|
2
|
+
import { planProjection } from "./shared.ts";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* A planning desk you can phone: plan-and-execute, with the caller in the loop.
|
|
@@ -18,6 +18,6 @@ export default agent({
|
|
|
18
18
|
// The plan exists before the first tool call, so a resumed connection has
|
|
19
19
|
// something to project.
|
|
20
20
|
// The plan, its progress and its revision trail, pushed after every tool call.
|
|
21
|
-
syncState:
|
|
21
|
+
syncState: planProjection,
|
|
22
22
|
greeting: "Planning desk. Tell me what you're trying to get done and I'll work out the steps.",
|
|
23
23
|
});
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import "@alexkroman1/aai-ui/styles.css";
|
|
2
2
|
import { AutoScroll, client, useAgentState, useTheme } from "@alexkroman1/aai-ui";
|
|
3
|
-
import
|
|
4
|
-
import { planSlot, planView } from "./shared.ts";
|
|
5
|
-
|
|
6
|
-
const EMPTY_PLAN: PlanView = planSlot.projection(planView)(undefined);
|
|
3
|
+
import { planProjection } from "./shared.ts";
|
|
7
4
|
|
|
8
5
|
/**
|
|
9
6
|
* The plan, ticking off.
|
|
@@ -14,7 +11,7 @@ const EMPTY_PLAN: PlanView = planSlot.projection(planView)(undefined);
|
|
|
14
11
|
*/
|
|
15
12
|
function PlanSidebar() {
|
|
16
13
|
const theme = useTheme();
|
|
17
|
-
const plan = useAgentState
|
|
14
|
+
const plan = useAgentState(planProjection);
|
|
18
15
|
|
|
19
16
|
if (!plan.objective) {
|
|
20
17
|
return (
|
|
@@ -159,3 +159,6 @@ export function planView(state: FrozenPlanState): PlanView {
|
|
|
159
159
|
progress: total === 0 ? 0 : done / total,
|
|
160
160
|
};
|
|
161
161
|
}
|
|
162
|
+
|
|
163
|
+
/** The projection BOTH ends use: `syncState` on the agent, `useAgentState` in the client. */
|
|
164
|
+
export const planProjection = planSlot.projection(planView);
|
|
@@ -65,8 +65,11 @@
|
|
|
65
65
|
* the run really submits the recording, really polls it, and really deletes it
|
|
66
66
|
* when it has to.
|
|
67
67
|
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
68
|
+
* Storage is what makes it DURABLE (`aai storage enable`, Settings → Database
|
|
69
|
+
* in the studio, or `DATABASE_URL` under `aai dev`) — runs and the
|
|
70
|
+
* correlation-key index both live there. Without it both live in the process:
|
|
71
|
+
* the flow still runs, and a recap parked on a callback does not survive the
|
|
72
|
+
* agent restarting, redeploying or going idle.
|
|
70
73
|
*/
|
|
71
74
|
|
|
72
75
|
import { agent } from "@alexkroman1/aai";
|
|
@@ -69,8 +69,11 @@
|
|
|
69
69
|
* thing that changes under `aai dev` (the key has to be in `.env`, not just your
|
|
70
70
|
* shell).
|
|
71
71
|
*
|
|
72
|
-
*
|
|
73
|
-
* runs and the key index
|
|
72
|
+
* Storage is what makes it DURABLE (`aai storage enable`, Settings → Database
|
|
73
|
+
* in the studio, or `DATABASE_URL` under `aai dev`) — runs and the key index
|
|
74
|
+
* both live there. Without it both live in the process, so a run in flight is
|
|
75
|
+
* lost when the agent restarts, redeploys or goes idle; everything below still
|
|
76
|
+
* works, which is what lets you try it before provisioning anything.
|
|
74
77
|
*/
|
|
75
78
|
|
|
76
79
|
import { agent } from "@alexkroman1/aai";
|
|
@@ -43,10 +43,16 @@ const statusColors: Record<string, string> = {
|
|
|
43
43
|
} satisfies Record<OrderStatus, string>;
|
|
44
44
|
|
|
45
45
|
// The sidebar before the first tool call, derived from the projection itself so
|
|
46
|
-
// a new StoreView field can't miss the pre-first-call render.
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
46
|
+
// a new StoreView field can't miss the pre-first-call render.
|
|
47
|
+
//
|
|
48
|
+
// **This is the one template that does NOT pass its projection to
|
|
49
|
+
// `useAgentState`**, and the reason is the browser bundle rather than style:
|
|
50
|
+
// that overload derives the empty frame by calling the projection, which calls
|
|
51
|
+
// the slot's `create()` — and this slot's factory lives in `store.ts` and pulls
|
|
52
|
+
// the 107 KB seed, so importing it here would ship the whole catalog to the
|
|
53
|
+
// browser. `emptyRetailState()` is the same shape without the seed. Reach for
|
|
54
|
+
// the projection overload everywhere the factory is cheap, which is every
|
|
55
|
+
// other stateful template.
|
|
50
56
|
const EMPTY_VIEW: StoreView = storeView(emptyRetailState());
|
|
51
57
|
|
|
52
58
|
// The dot's colour per session state, as an EXHAUSTIVE map rather than an
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { agent } from "@alexkroman1/aai";
|
|
2
|
-
import {
|
|
2
|
+
import { gameProjection } from "./shared.ts";
|
|
3
3
|
|
|
4
4
|
export default agent({
|
|
5
5
|
name: "Solo RPG",
|
|
@@ -14,5 +14,5 @@ export default agent({
|
|
|
14
14
|
// remembering to push or watching the UI quietly fall out of sync.
|
|
15
15
|
// The identity projection: this campaign IS what the client renders, and the
|
|
16
16
|
// slot's own default is what a session that has run no tool projects.
|
|
17
|
-
syncState:
|
|
17
|
+
syncState: gameProjection,
|
|
18
18
|
});
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
type Disposition,
|
|
8
8
|
type GameState,
|
|
9
9
|
GENRES,
|
|
10
|
-
|
|
10
|
+
gameProjection,
|
|
11
11
|
MAX_RESOURCE,
|
|
12
12
|
MIN_MOMENTUM,
|
|
13
13
|
type NPC,
|
|
@@ -15,18 +15,6 @@ import {
|
|
|
15
15
|
type TIME_PHASES,
|
|
16
16
|
} from "./shared.ts";
|
|
17
17
|
|
|
18
|
-
// The campaign before the first tool call, derived from the SAME projection the
|
|
19
|
-
// server pushes rather than from the module default beside it — so a session
|
|
20
|
-
// that has run no tool renders exactly what its first push will replace. Every
|
|
21
|
-
// sibling template derives its fallback this way; this one read `DEFAULT_STATE`
|
|
22
|
-
// directly, which is a second copy of "what an untouched session looks like".
|
|
23
|
-
//
|
|
24
|
-
// It is typed READONLY because that is what the projection produces, and a
|
|
25
|
-
// client only ever renders what the server pushed — so the four components
|
|
26
|
-
// below take a readonly campaign too, which is the deep-readonly slot
|
|
27
|
-
// propagating exactly as far as it should.
|
|
28
|
-
const EMPTY_GAME = gameSlot.projection((game) => game)(undefined);
|
|
29
|
-
|
|
30
18
|
// ── Color Palette ────────────────────────────────────────────────────────────
|
|
31
19
|
const C = {
|
|
32
20
|
bg: "#0a0a0c",
|
|
@@ -784,7 +772,10 @@ function SoloRPGApp() {
|
|
|
784
772
|
// The agent's own session state, projected by `syncState` and pushed after
|
|
785
773
|
// every tool call — no per-tool `ctx.send`, and nothing to keep in step
|
|
786
774
|
// when a new tool starts mutating the game.
|
|
787
|
-
|
|
775
|
+
// `gameProjection` carries both halves: the campaign's readonly type — which
|
|
776
|
+
// is what the four components below take — and the frame a session that has
|
|
777
|
+
// run no tool renders.
|
|
778
|
+
const game = useAgentState(gameProjection);
|
|
788
779
|
|
|
789
780
|
return (
|
|
790
781
|
<StartScreen
|
|
@@ -328,6 +328,13 @@ export const DEFAULT_STATE: GameState = {
|
|
|
328
328
|
// another's.
|
|
329
329
|
export const gameSlot = sessionSlot("game", () => structuredClone(DEFAULT_STATE));
|
|
330
330
|
|
|
331
|
+
/**
|
|
332
|
+
* The IDENTITY projection, used by `syncState` and by `useAgentState`: this
|
|
333
|
+
* campaign IS what the client renders, so there is nothing to trim — and it
|
|
334
|
+
* carries the deep-readonly type the client's components take.
|
|
335
|
+
*/
|
|
336
|
+
export const gameProjection = gameSlot.projection((game) => game);
|
|
337
|
+
|
|
331
338
|
/**
|
|
332
339
|
* The game as a READ hands it out: deep-frozen, and typed to say so.
|
|
333
340
|
*
|
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
GROUNDED_SYSTEM,
|
|
32
32
|
REWRITE_SYSTEM,
|
|
33
33
|
} from "./prompts.ts";
|
|
34
|
-
import { retrieve, supportSlot, supportView } from "./shared.ts";
|
|
34
|
+
import { retrieve, supportProjection, supportSlot, supportView } from "./shared.ts";
|
|
35
35
|
|
|
36
36
|
// ─── A scripted model ────────────────────────────────────────────────────────
|
|
37
37
|
//
|
|
@@ -332,8 +332,9 @@ describe("log_ticket", () => {
|
|
|
332
332
|
|
|
333
333
|
describe("supportView projection", () => {
|
|
334
334
|
test("an untouched call projects an empty trace, not undefined", () => {
|
|
335
|
-
// Exactly the
|
|
336
|
-
|
|
335
|
+
// Exactly the frame `client.tsx` renders before the first push — it passes
|
|
336
|
+
// this same projection to `useAgentState`.
|
|
337
|
+
expect(supportProjection()).toMatchObject({
|
|
337
338
|
product: "Meridian Fibre",
|
|
338
339
|
trace: null,
|
|
339
340
|
asked: [],
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { agent } from "@alexkroman1/aai";
|
|
2
|
-
import { PRODUCT,
|
|
2
|
+
import { PRODUCT, supportProjection } from "./shared.ts";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* A support line that grades its own retrieval before it speaks — the
|
|
@@ -22,6 +22,6 @@ export default agent({
|
|
|
22
22
|
// something to project.
|
|
23
23
|
// The projection is also the privacy boundary: a logged ticket carries the
|
|
24
24
|
// caller's callback number, and only its reference crosses to the browser.
|
|
25
|
-
syncState:
|
|
25
|
+
syncState: supportProjection,
|
|
26
26
|
greeting: `${PRODUCT} support, you're through to the automated line. What's happened?`,
|
|
27
27
|
});
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import "@alexkroman1/aai-ui/styles.css";
|
|
2
2
|
import { AutoScroll, client, useAgentState, useTheme } from "@alexkroman1/aai-ui";
|
|
3
|
-
import
|
|
4
|
-
import { PRODUCT, supportSlot, supportView } from "./shared.ts";
|
|
5
|
-
|
|
6
|
-
const EMPTY_SUPPORT: SupportView = supportSlot.projection(supportView)(undefined);
|
|
3
|
+
import { PRODUCT, supportProjection } from "./shared.ts";
|
|
7
4
|
|
|
8
5
|
/**
|
|
9
6
|
* The graph, as it ran for the last question.
|
|
@@ -15,7 +12,7 @@ const EMPTY_SUPPORT: SupportView = supportSlot.projection(supportView)(undefined
|
|
|
15
12
|
*/
|
|
16
13
|
function TraceSidebar() {
|
|
17
14
|
const theme = useTheme();
|
|
18
|
-
const support = useAgentState
|
|
15
|
+
const support = useAgentState(supportProjection);
|
|
19
16
|
const trace = support.trace;
|
|
20
17
|
|
|
21
18
|
return (
|
|
@@ -238,3 +238,6 @@ export function supportView(state: FrozenSupportState): SupportView {
|
|
|
238
238
|
ticket: state.ticket?.reference ?? null,
|
|
239
239
|
};
|
|
240
240
|
}
|
|
241
|
+
|
|
242
|
+
/** The projection BOTH ends use: `syncState` on the agent, `useAgentState` in the client. */
|
|
243
|
+
export const supportProjection = supportSlot.projection(supportView);
|
|
@@ -17,8 +17,10 @@
|
|
|
17
17
|
* `aai secret put ASSEMBLYAI_API_KEY` once deployed. `requiredEnv` below is
|
|
18
18
|
* what makes a deploy check for it rather than letting the first run find out.
|
|
19
19
|
* A step reads it with `requireStepEnv`; see `@alexkroman1/aai/utils`.
|
|
20
|
-
* - **Storage** (`aai storage enable`,
|
|
21
|
-
*
|
|
20
|
+
* - **Storage** (`aai storage enable`, Settings → Database in the studio, or
|
|
21
|
+
* `DATABASE_URL` under `aai dev`). REQUIRED here, unlike most workflow apps:
|
|
22
|
+
* a run survives without it, but an UPLOAD's record is a row, so the form
|
|
23
|
+
* below refuses by name until storage is on.
|
|
22
24
|
* - **A linear-PCM WAV.** The cutting is arithmetic over byte offsets, which is
|
|
23
25
|
* only possible on uncompressed audio; `workflows/wav.ts` says so in more
|
|
24
26
|
* detail, and an unsupported file fails the run by name with the `ffmpeg`
|
|
@@ -168,7 +168,6 @@ import {
|
|
|
168
168
|
} from "@alexkroman1/aai-ui";
|
|
169
169
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
170
170
|
import type { transcribe } from "./agent.ts";
|
|
171
|
-
import { ApiHelp } from "./api-help.tsx";
|
|
172
171
|
import {
|
|
173
172
|
clock,
|
|
174
173
|
countWords,
|
|
@@ -463,10 +462,6 @@ function TranscriptionDesk() {
|
|
|
463
462
|
openId={openId}
|
|
464
463
|
onOpen={(runId) => setOpenId((current) => (current === runId ? undefined : runId))}
|
|
465
464
|
/>
|
|
466
|
-
|
|
467
|
-
{/* The most useful thing about a workflow app is the least discoverable:
|
|
468
|
-
this page is one caller of an ordinary HTTP API. See `api-help.tsx`. */}
|
|
469
|
-
<ApiHelp />
|
|
470
465
|
</main>
|
|
471
466
|
);
|
|
472
467
|
}
|
|
@@ -18,7 +18,7 @@ const agentDef = withDiscoveredTools(
|
|
|
18
18
|
import.meta.glob("./tools/*.ts", { eager: true }),
|
|
19
19
|
);
|
|
20
20
|
|
|
21
|
-
import { activeAssistant, FLIGHTS, tripSlot, tripView } from "./shared.ts";
|
|
21
|
+
import { activeAssistant, FLIGHTS, tripProjection, tripSlot, tripView } from "./shared.ts";
|
|
22
22
|
|
|
23
23
|
// ─── Harness ─────────────────────────────────────────────────────────────────
|
|
24
24
|
|
|
@@ -298,8 +298,9 @@ describe("search tools", () => {
|
|
|
298
298
|
|
|
299
299
|
describe("tripView projection", () => {
|
|
300
300
|
test("an untouched call projects the seeded booking at the concierge desk", () => {
|
|
301
|
-
// Exactly the
|
|
302
|
-
|
|
301
|
+
// Exactly the frame `client.tsx` renders before the first push — it passes
|
|
302
|
+
// this same projection to `useAgentState`.
|
|
303
|
+
const view = tripProjection();
|
|
303
304
|
expect(view).toMatchObject({
|
|
304
305
|
assistant: "primary",
|
|
305
306
|
assistantTitle: "concierge",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { agent } from "@alexkroman1/aai";
|
|
2
|
-
import {
|
|
2
|
+
import { tripProjection } from "./shared.ts";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* A phone travel concierge, adapted from LangGraph's customer-support tutorial.
|
|
@@ -23,7 +23,7 @@ export default agent({
|
|
|
23
23
|
// so a resumed connection has something to project.
|
|
24
24
|
// One projection replaces a `ctx.send` in each of eleven tools — and is the
|
|
25
25
|
// single place that decides the caller's record leaves the server trimmed.
|
|
26
|
-
syncState:
|
|
26
|
+
syncState: tripProjection,
|
|
27
27
|
greeting:
|
|
28
28
|
"Swiss Air Travel, this is the concierge desk. I can see your booking — what can I do for you today?",
|
|
29
29
|
});
|
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
import "@alexkroman1/aai-ui/styles.css";
|
|
2
2
|
import { AutoScroll, client, useAgentState, useTheme } from "@alexkroman1/aai-ui";
|
|
3
3
|
import type { TripView } from "./shared.ts";
|
|
4
|
-
import { SPECIALIST_IDS, SPECIALISTS,
|
|
5
|
-
|
|
6
|
-
// Derived from the projection so a new TripView field cannot silently miss the
|
|
7
|
-
// pre-first-tool-call render.
|
|
8
|
-
const EMPTY_TRIP: TripView = tripSlot.projection(tripView)(undefined);
|
|
4
|
+
import { SPECIALIST_IDS, SPECIALISTS, tripProjection } from "./shared.ts";
|
|
9
5
|
|
|
10
6
|
const DESKS = ["primary", ...SPECIALIST_IDS] as const;
|
|
11
7
|
|
|
@@ -44,7 +40,7 @@ function DeskStrip({ active }: { active: TripView["assistant"] }) {
|
|
|
44
40
|
|
|
45
41
|
function ItinerarySidebar() {
|
|
46
42
|
const theme = useTheme();
|
|
47
|
-
const trip = useAgentState
|
|
43
|
+
const trip = useAgentState(tripProjection);
|
|
48
44
|
|
|
49
45
|
return (
|
|
50
46
|
<div className="flex flex-col gap-4 p-4 h-full min-h-0" style={{ color: theme.text }}>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alexkroman1/aai-cli",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.10.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"aai": "bin.mjs"
|
|
@@ -44,8 +44,8 @@
|
|
|
44
44
|
"p-timeout": "^7.0.1",
|
|
45
45
|
"vite": "^8.2.1",
|
|
46
46
|
"zod": "^4.4.3",
|
|
47
|
-
"@alexkroman1/aai
|
|
48
|
-
"@alexkroman1/aai": "6.
|
|
47
|
+
"@alexkroman1/aai": "6.10.0",
|
|
48
|
+
"@alexkroman1/aai-ui": "6.10.0"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"playwright": "^1.62.1",
|
|
@@ -1,214 +0,0 @@
|
|
|
1
|
-
// Copyright 2026 the AAI authors. MIT license.
|
|
2
|
-
/**
|
|
3
|
-
* "You do not need this page" — the HTTP API, on the page.
|
|
4
|
-
*
|
|
5
|
-
* A workflow app's whole surface is `GET|POST /workflows/*`, and the page is one
|
|
6
|
-
* caller of it. That is the most useful thing about the shape and the least
|
|
7
|
-
* discoverable: nothing in a form suggests that the same work is three `curl`
|
|
8
|
-
* calls, that a run id is the entire handle (no session, no cookie), or that a
|
|
9
|
-
* transcript can be collected days later from another machine. So the recipes are
|
|
10
|
-
* rendered where somebody is already looking, rather than left in a README they
|
|
11
|
-
* would have to know exists.
|
|
12
|
-
*
|
|
13
|
-
* ## It links to the LIVE listing
|
|
14
|
-
*
|
|
15
|
-
* `GET /workflows` serves each workflow's name, description and input schema — the
|
|
16
|
-
* same JSON `<WorkflowFields>` renders this form from. So the link is not
|
|
17
|
-
* documentation about the API, it is the API answering for itself, on this
|
|
18
|
-
* deployment, at this version. A reader who wants the schema gets the real one;
|
|
19
|
-
* a reader whose agent is behind `AAI_WORKFLOW_API_TOKEN` gets a 401, which is
|
|
20
|
-
* also the truth.
|
|
21
|
-
*
|
|
22
|
-
* ## The three recipes are the three flows
|
|
23
|
-
*
|
|
24
|
-
* The first two differ only in who names the upload — which is the whole of what
|
|
25
|
-
* lets one of them start before the bytes are in — and the third does none of that
|
|
26
|
-
* work at all. Showing them side by side is the clearest statement of the trade
|
|
27
|
-
* available, and cheaper than the prose that would otherwise have to make it.
|
|
28
|
-
*
|
|
29
|
-
* They are also KEPT HONEST by being runnable: every one of these was executed
|
|
30
|
-
* against a real dev server, which is how two bugs in the streaming path were
|
|
31
|
-
* found (a missing wake after the upload, and a poll that slept on a stale view).
|
|
32
|
-
*/
|
|
33
|
-
|
|
34
|
-
/** @jsxImportSource react */
|
|
35
|
-
|
|
36
|
-
import type { ReactNode } from "react";
|
|
37
|
-
|
|
38
|
-
/** The API root, relative to wherever this page is served from. */
|
|
39
|
-
const API = "workflows";
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* One shell recipe, with a heading and a note.
|
|
43
|
-
*
|
|
44
|
-
* Rendered in a `<pre>` rather than assembled from styled spans: it exists to be
|
|
45
|
-
* SELECTED and pasted, and any markup inside the block is markup a copy picks up.
|
|
46
|
-
*/
|
|
47
|
-
function Recipe({
|
|
48
|
-
title,
|
|
49
|
-
note,
|
|
50
|
-
script,
|
|
51
|
-
}: {
|
|
52
|
-
title: string;
|
|
53
|
-
note: string;
|
|
54
|
-
script: string;
|
|
55
|
-
}): ReactNode {
|
|
56
|
-
return (
|
|
57
|
-
<section className="flex flex-col gap-2">
|
|
58
|
-
<h4 className="text-xs font-medium uppercase tracking-[1.2px]">{title}</h4>
|
|
59
|
-
<p className="text-xs opacity-70">{note}</p>
|
|
60
|
-
<pre className="overflow-x-auto rounded-md border p-3 text-xs leading-relaxed">{script}</pre>
|
|
61
|
-
</section>
|
|
62
|
-
);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** Store the whole file, then start a run on its id. */
|
|
66
|
-
const CLASSIC = `# 1. store the recording. Answers 201 once the LAST byte is in, which is why
|
|
67
|
-
# this shape cannot start the run any earlier.
|
|
68
|
-
ID=$(curl -s -X POST "$AGENT/workflows/uploads?name=standup.wav" \
|
|
69
|
-
-H 'content-type: audio/wav' --data-binary @standup.wav | jq -r .id)
|
|
70
|
-
|
|
71
|
-
# 2. run it. \`wait\` holds the request open for up to 60s; drop it to get
|
|
72
|
-
# { runId } straight back and poll instead.
|
|
73
|
-
curl -s -X POST "$AGENT/workflows/runs" \
|
|
74
|
-
-H 'content-type: application/json' \
|
|
75
|
-
-d "{\\"workflow\\":\\"transcribe\\",\\"wait\\":60000,
|
|
76
|
-
\\"input\\":{\\"recording\\":\\"$ID\\"}}" | jq -r '.run.output.transcript'`;
|
|
77
|
-
|
|
78
|
-
/** Start the run first, then stream the file to the id it is watching. */
|
|
79
|
-
const STREAMING = `# No splitting, no ffmpeg — the file goes in ONE request. The only difference
|
|
80
|
-
# from the recipe above is that YOU pick the upload id, so it is already valid
|
|
81
|
-
# when the run starts and the run reads the bytes as they land.
|
|
82
|
-
|
|
83
|
-
# 1. pick an id and start the run on it. Nothing has been uploaded yet.
|
|
84
|
-
ID=$(openssl rand -hex 16)
|
|
85
|
-
RUN=$(curl -s -X POST "$AGENT/workflows/runs" \
|
|
86
|
-
-H 'content-type: application/json' \
|
|
87
|
-
-d "{\\"workflow\\":\\"transcribeStream\\",
|
|
88
|
-
\\"input\\":{\\"recording\\":\\"$ID\\"}}" | jq -r .runId)
|
|
89
|
-
|
|
90
|
-
# 2. PUT the whole file. The upload record exists from the first byte with
|
|
91
|
-
# complete:false, and its size grows — which is what the run polls.
|
|
92
|
-
curl -s -X PUT "$AGENT/workflows/uploads/$ID?name=standup.wav" \
|
|
93
|
-
-H 'content-type: audio/wav' --data-binary @standup.wav | jq -c '{size, complete}'
|
|
94
|
-
|
|
95
|
-
# 3. wake it. The run sleeps between polls, so without this it notices the file
|
|
96
|
-
# is finished up to one poll interval late — every time.
|
|
97
|
-
curl -s -X POST "$AGENT/workflows/runs/$RUN/wake" > /dev/null
|
|
98
|
-
|
|
99
|
-
# 4. collect it whenever — a run id is the whole handle.
|
|
100
|
-
curl -s "$AGENT/workflows/runs/$RUN?wait=60000" | jq -r '.run.output.transcript'
|
|
101
|
-
|
|
102
|
-
# While it runs, from any other shell:
|
|
103
|
-
# curl -s "$AGENT/workflows/uploads/$ID/info" # how much has arrived
|
|
104
|
-
# curl -sN "$AGENT/workflows/runs/$RUN/stream" # what the run is saying`;
|
|
105
|
-
|
|
106
|
-
/** Hand the whole thing to the async API. */
|
|
107
|
-
const BATCH = `# The same two requests as the first recipe — only the workflow name differs.
|
|
108
|
-
# No cutting happens anywhere: the run uploads your file to the async API,
|
|
109
|
-
# polls the job, and reads the text. It also accepts mp3 and m4a, which the
|
|
110
|
-
# two sync flows refuse.
|
|
111
|
-
ID=$(curl -s -X POST "$AGENT/workflows/uploads?name=standup.m4a" \
|
|
112
|
-
-H 'content-type: audio/mp4' --data-binary @standup.m4a | jq -r .id)
|
|
113
|
-
|
|
114
|
-
# No \`wait\` here: an async job takes minutes, well past the 60s ceiling a
|
|
115
|
-
# synchronous read can hold. Start it, then follow the run.
|
|
116
|
-
RUN=$(curl -s -X POST "$AGENT/workflows/runs" \
|
|
117
|
-
-H 'content-type: application/json' \
|
|
118
|
-
-d "{\\"workflow\\":\\"transcribeBatch\\",
|
|
119
|
-
\\"input\\":{\\"recording\\":\\"$ID\\"}}" | jq -r .runId)
|
|
120
|
-
|
|
121
|
-
curl -sN "$AGENT/workflows/runs/$RUN/events" # status, as it changes
|
|
122
|
-
curl -s "$AGENT/workflows/runs/$RUN" | jq -r '.output.transcript // .status'`;
|
|
123
|
-
|
|
124
|
-
/** Every route this app answers, and what each is for. */
|
|
125
|
-
const ROUTES: readonly { route: string; does: string }[] = [
|
|
126
|
-
{ route: "GET /workflows", does: "the three workflows and their input schemas" },
|
|
127
|
-
{ route: "POST /workflows/runs", does: "start a run · body names workflow and input" },
|
|
128
|
-
{ route: "GET /workflows/runs", does: "runs so far · filter by workflow, key, limit" },
|
|
129
|
-
{ route: "GET /workflows/runs/:id", does: "one run · add wait=<ms> to block on it" },
|
|
130
|
-
{ route: "GET /workflows/runs/:id/events", does: "SSE, status transitions" },
|
|
131
|
-
{ route: "GET /workflows/runs/:id/stream", does: "SSE, what the run has written" },
|
|
132
|
-
{ route: "POST /workflows/runs/:id/wake", does: "end a pending sleep early" },
|
|
133
|
-
{ route: "DELETE /workflows/runs/:id", does: "cancel it" },
|
|
134
|
-
{ route: "POST /workflows/uploads", does: "store a file, id minted by the store" },
|
|
135
|
-
{
|
|
136
|
-
route: "PUT /workflows/uploads/:id",
|
|
137
|
-
does: "store a file under YOUR id, readable as it arrives",
|
|
138
|
-
},
|
|
139
|
-
{
|
|
140
|
-
route: "POST /workflows/uploads/:id/parts",
|
|
141
|
-
does: "declare an upload its parts fill in · ?total=<bytes>",
|
|
142
|
-
},
|
|
143
|
-
{
|
|
144
|
-
route: "PUT /workflows/uploads/:id/parts",
|
|
145
|
-
does: "one window of it · ?offset=<byte>, sent concurrently",
|
|
146
|
-
},
|
|
147
|
-
{ route: "GET /workflows/uploads/:id", does: "read the bytes back · Range honoured" },
|
|
148
|
-
{ route: "GET /workflows/uploads/:id/info", does: "name, bytes stored so far, and complete" },
|
|
149
|
-
];
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* The whole API, collapsed by default.
|
|
153
|
-
*
|
|
154
|
-
* `<details>` rather than a state hook: the browser owns disclosure, it is
|
|
155
|
-
* keyboard-accessible and findable by in-page search without anyone wiring either,
|
|
156
|
-
* and a page that renders a transcript should not re-render because somebody
|
|
157
|
-
* expanded a help panel.
|
|
158
|
-
*/
|
|
159
|
-
export function ApiHelp(): ReactNode {
|
|
160
|
-
return (
|
|
161
|
-
<details className="rounded-md border">
|
|
162
|
-
<summary className="cursor-pointer p-4 text-sm font-medium">
|
|
163
|
-
Use the API without this page
|
|
164
|
-
</summary>
|
|
165
|
-
<div className="flex flex-col gap-6 border-t p-4">
|
|
166
|
-
<p className="text-sm opacity-70">
|
|
167
|
-
This page is one caller. Everything it does is plain HTTP on this agent's own origin,
|
|
168
|
-
unauthenticated unless the deployment sets{" "}
|
|
169
|
-
<code className="text-xs">AAI_WORKFLOW_API_TOKEN</code> (then every route wants{" "}
|
|
170
|
-
<code className="text-xs">Authorization: Bearer …</code>). A run id is the whole handle —
|
|
171
|
-
no session, no cookie — so a transcript can be collected from another machine, days later.
|
|
172
|
-
</p>
|
|
173
|
-
<p className="text-sm">
|
|
174
|
-
{/* The API answering for itself: same JSON this form was rendered from. */}
|
|
175
|
-
<a className="underline" href={API} target="_blank" rel="noreferrer">
|
|
176
|
-
{`GET ${API}`}
|
|
177
|
-
</a>{" "}
|
|
178
|
-
<span className="opacity-70">
|
|
179
|
-
— the live listing, including each workflow's input schema. Set{" "}
|
|
180
|
-
<code className="text-xs">AGENT</code> to this page's origin for the recipes below.
|
|
181
|
-
</span>
|
|
182
|
-
</p>
|
|
183
|
-
|
|
184
|
-
<Recipe
|
|
185
|
-
title="Store it, then transcribe"
|
|
186
|
-
note="Sync API. One request per phase, and the upload has to finish before there is a run."
|
|
187
|
-
script={CLASSIC}
|
|
188
|
-
/>
|
|
189
|
-
<Recipe
|
|
190
|
-
title="Transcribe while it uploads"
|
|
191
|
-
note="Sync API, one upload request, no splitting. The run starts on an id you chose and reads the bytes as they land."
|
|
192
|
-
script={STREAMING}
|
|
193
|
-
/>
|
|
194
|
-
<Recipe
|
|
195
|
-
title="Let the provider do it"
|
|
196
|
-
note="Async API. No cutting and no seams, it accepts compressed audio, and the wait belongs to the provider's queue."
|
|
197
|
-
script={BATCH}
|
|
198
|
-
/>
|
|
199
|
-
|
|
200
|
-
<section className="flex flex-col gap-2">
|
|
201
|
-
<h4 className="text-xs font-medium uppercase tracking-[1.2px]">Every route</h4>
|
|
202
|
-
<ul className="flex flex-col gap-1">
|
|
203
|
-
{ROUTES.map((entry) => (
|
|
204
|
-
<li key={entry.route} className="flex flex-col gap-0.5 text-xs sm:flex-row sm:gap-3">
|
|
205
|
-
<code className="shrink-0 sm:w-72">{entry.route}</code>
|
|
206
|
-
<span className="opacity-70">{entry.does}</span>
|
|
207
|
-
</li>
|
|
208
|
-
))}
|
|
209
|
-
</ul>
|
|
210
|
-
</section>
|
|
211
|
-
</div>
|
|
212
|
-
</details>
|
|
213
|
-
);
|
|
214
|
-
}
|