@alexkroman1/aai-cli 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/dist/_build-p1HHkdon.mjs +132 -0
  3. package/dist/_discover-BzlCDVZ6.mjs +161 -0
  4. package/dist/_init-l_uoyFCN.mjs +82 -0
  5. package/dist/_link-BGXGFYWa.mjs +47 -0
  6. package/dist/_server-common-qLA1QU2C.mjs +36 -0
  7. package/dist/_ui-kJIua5L9.mjs +44 -0
  8. package/dist/cli.mjs +318 -0
  9. package/dist/deploy-KyNJaoP5.mjs +86 -0
  10. package/dist/dev-DBFvKyzk.mjs +39 -0
  11. package/dist/init-BWG5OrQa.mjs +65 -0
  12. package/dist/rag-BnCMnccf.mjs +173 -0
  13. package/dist/secret-CzeHIGzE.mjs +50 -0
  14. package/dist/start-C1qkhU4O.mjs +23 -0
  15. package/package.json +39 -0
  16. package/templates/_shared/.env.example +5 -0
  17. package/templates/_shared/CLAUDE.md +1051 -0
  18. package/templates/_shared/biome.json +32 -0
  19. package/templates/_shared/global.d.ts +1 -0
  20. package/templates/_shared/index.html +16 -0
  21. package/templates/_shared/package.json +23 -0
  22. package/templates/_shared/tsconfig.json +15 -0
  23. package/templates/code-interpreter/agent.ts +27 -0
  24. package/templates/code-interpreter/client.tsx +3 -0
  25. package/templates/css.d.ts +1 -0
  26. package/templates/dispatch-center/agent.ts +1227 -0
  27. package/templates/dispatch-center/client.tsx +505 -0
  28. package/templates/embedded-assets/agent.ts +48 -0
  29. package/templates/embedded-assets/client.tsx +3 -0
  30. package/templates/embedded-assets/knowledge.json +20 -0
  31. package/templates/health-assistant/agent.ts +160 -0
  32. package/templates/health-assistant/client.tsx +3 -0
  33. package/templates/infocom-adventure/agent.ts +164 -0
  34. package/templates/infocom-adventure/client.tsx +300 -0
  35. package/templates/math-buddy/agent.ts +21 -0
  36. package/templates/math-buddy/client.tsx +3 -0
  37. package/templates/memory-agent/agent.ts +20 -0
  38. package/templates/memory-agent/client.tsx +3 -0
  39. package/templates/night-owl/agent.ts +98 -0
  40. package/templates/night-owl/client.tsx +12 -0
  41. package/templates/personal-finance/agent.ts +26 -0
  42. package/templates/personal-finance/client.tsx +3 -0
  43. package/templates/pizza-ordering/agent.ts +218 -0
  44. package/templates/pizza-ordering/client.tsx +264 -0
  45. package/templates/simple/agent.ts +6 -0
  46. package/templates/simple/client.tsx +3 -0
  47. package/templates/smart-research/agent.ts +164 -0
  48. package/templates/smart-research/client.tsx +3 -0
  49. package/templates/solo-rpg/agent.ts +1244 -0
  50. package/templates/solo-rpg/client.tsx +698 -0
  51. package/templates/support/README.md +62 -0
  52. package/templates/support/agent.ts +19 -0
  53. package/templates/support/client.tsx +3 -0
  54. package/templates/travel-concierge/agent.ts +29 -0
  55. package/templates/travel-concierge/client.tsx +3 -0
  56. package/templates/tsconfig.json +1 -0
  57. package/templates/web-researcher/agent.ts +17 -0
  58. package/templates/web-researcher/client.tsx +3 -0
@@ -0,0 +1,505 @@
1
+ import "@alexkroman1/aai-ui/styles.css";
2
+ import { mount, useSession } from "@alexkroman1/aai-ui";
3
+ import type { Message } from "@alexkroman1/aai-ui";
4
+ import { useEffect, useRef } from "preact/hooks";
5
+
6
+ const CSS = `
7
+ @keyframes dc-pulse {
8
+ 0%, 100% { opacity: 1; }
9
+ 50% { opacity: 0.5; }
10
+ }
11
+ @keyframes dc-slide-in {
12
+ from { transform: translateY(10px); opacity: 0; }
13
+ to { transform: translateY(0); opacity: 1; }
14
+ }
15
+ .dc-messages::-webkit-scrollbar { width: 6px; }
16
+ .dc-messages::-webkit-scrollbar-track { background: transparent; }
17
+ .dc-messages::-webkit-scrollbar-thumb { background: #334155; border-radius: 3px; }
18
+ .dc-sidebar::-webkit-scrollbar { width: 6px; }
19
+ .dc-sidebar::-webkit-scrollbar-track { background: transparent; }
20
+ .dc-sidebar::-webkit-scrollbar-thumb { background: #334155; border-radius: 3px; }
21
+ @media (max-width: 900px) {
22
+ .dc-main { grid-template-columns: 1fr !important; grid-template-rows: auto 1fr !important; }
23
+ }
24
+ `;
25
+
26
+ const alertColors: Record<string, string> = {
27
+ green: "#22c55e",
28
+ yellow: "#eab308",
29
+ orange: "#f97316",
30
+ red: "#ef4444",
31
+ };
32
+
33
+ const severityColors: Record<string, string> = {
34
+ critical: "#ef4444",
35
+ urgent: "#f97316",
36
+ moderate: "#eab308",
37
+ minor: "#22c55e",
38
+ };
39
+
40
+ const statusColors: Record<string, string> = {
41
+ incoming: "#818cf8",
42
+ triaged: "#a78bfa",
43
+ dispatched: "#f59e0b",
44
+ "en_route": "#3b82f6",
45
+ "on_scene": "#22c55e",
46
+ resolved: "#6b7280",
47
+ escalated: "#ef4444",
48
+ };
49
+
50
+ interface Incident {
51
+ id: string;
52
+ mentioned: number;
53
+ severity?: string;
54
+ status?: string;
55
+ location?: string;
56
+ }
57
+
58
+ function extractIncidents(
59
+ messages: { role: string; content: string }[],
60
+ ): Map<string, Incident> {
61
+ const incidents = new Map<string, Incident>();
62
+ for (const msg of messages) {
63
+ const incMatches = msg.content.matchAll(/INC-\d{4}/g);
64
+ for (const m of incMatches) {
65
+ const id = m[0];
66
+ if (!incidents.has(id)) {
67
+ incidents.set(id, { id, mentioned: 0 });
68
+ }
69
+ incidents.get(id)!.mentioned++;
70
+ }
71
+
72
+ const lines = msg.content.split("\n");
73
+ for (const line of lines) {
74
+ const idMatch = line.match(/INC-\d{4}/);
75
+ if (!idMatch) continue;
76
+ const id = idMatch[0];
77
+ const inc = incidents.get(id) || { id, mentioned: 0 };
78
+
79
+ for (const sev of ["critical", "urgent", "moderate", "minor"]) {
80
+ if (line.toLowerCase().includes(sev)) inc.severity = sev;
81
+ }
82
+ for (
83
+ const st of [
84
+ "incoming",
85
+ "triaged",
86
+ "dispatched",
87
+ "en_route",
88
+ "on_scene",
89
+ "resolved",
90
+ "escalated",
91
+ ]
92
+ ) {
93
+ if (
94
+ line.toLowerCase().includes(st.replace("_", " ")) ||
95
+ line.toLowerCase().includes(st)
96
+ ) inc.status = st;
97
+ }
98
+ const locMatch = line.match(/(?:at|to|location:?)\s+([^,.\n]{5,50})/i);
99
+ if (locMatch) inc.location = locMatch[1]!.trim();
100
+
101
+ incidents.set(id, inc);
102
+ }
103
+ }
104
+ return incidents;
105
+ }
106
+
107
+ function extractAlertLevel(
108
+ messages: { role: string; content: string }[],
109
+ ): string {
110
+ let level = "green";
111
+ for (const msg of messages) {
112
+ const match = msg.content.match(/alert level[:\s]+(\w+)/i);
113
+ if (match) level = match[1]!.toLowerCase();
114
+ if (
115
+ msg.content.includes("alert level is red") ||
116
+ msg.content.includes("ALERT: RED")
117
+ ) level = "red";
118
+ if (msg.content.includes("alert level is orange")) level = "orange";
119
+ if (msg.content.includes("alert level is yellow")) level = "yellow";
120
+ }
121
+ return level;
122
+ }
123
+
124
+ function stateColor(state: string): string {
125
+ return state === "listening"
126
+ ? "#22c55e"
127
+ : state === "thinking"
128
+ ? "#eab308"
129
+ : state === "speaking"
130
+ ? "#3b82f6"
131
+ : state === "ready"
132
+ ? "#22c55e"
133
+ : state === "error"
134
+ ? "#ef4444"
135
+ : "#6b7280";
136
+ }
137
+
138
+ function Panel(
139
+ { title, children }: { title: string; children: preact.ComponentChildren },
140
+ ) {
141
+ return (
142
+ <div
143
+ class="rounded-lg p-3"
144
+ style={{ background: "#1a1a2e", border: "1px solid #1e293b" }}
145
+ >
146
+ <div
147
+ class="text-[10px] font-bold uppercase tracking-[1.5px] mb-2.5"
148
+ style={{ color: "#64748b" }}
149
+ >
150
+ {title}
151
+ </div>
152
+ {children}
153
+ </div>
154
+ );
155
+ }
156
+
157
+ function StatRow(
158
+ { label, value, color }: { label: string; value: number; color?: string },
159
+ ) {
160
+ return (
161
+ <div class="flex justify-between items-center py-1 text-xs">
162
+ <span style={{ color: "#94a3b8" }}>{label}</span>
163
+ <span class="font-bold" style={{ color: color || "#e2e8f0" }}>
164
+ {value}
165
+ </span>
166
+ </div>
167
+ );
168
+ }
169
+
170
+ function App() {
171
+ const ctrl = useSession();
172
+ const { session } = ctrl;
173
+ const msgs = session.messages.value;
174
+ const tx = session.userUtterance.value;
175
+ const state = session.state.value;
176
+ const error = session.error.value;
177
+ const messagesEndRef = useRef<HTMLDivElement>(null);
178
+
179
+ const incidents = extractIncidents(msgs);
180
+ const alertLevel = extractAlertLevel(msgs);
181
+
182
+ useEffect(() => {
183
+ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
184
+ }, [msgs.length]);
185
+
186
+ const incidentList = Array.from(incidents.values()).reverse();
187
+ const activeIncidents = incidentList.filter((i) => i.status !== "resolved");
188
+ const resolvedCount = incidentList.filter((i) => i.status === "resolved")
189
+ .length;
190
+
191
+ const alertBg = alertColors[alertLevel] || "#6b7280";
192
+ const alertTextColor = alertLevel === "yellow" ? "#000" : "#fff";
193
+
194
+ return (
195
+ <>
196
+ <style>{CSS}</style>
197
+ <div
198
+ class="flex flex-col min-h-screen m-0 p-0 font-mono"
199
+ style={{ background: "#0a0a0f", color: "#e2e8f0" }}
200
+ >
201
+ {/* Header */}
202
+ <div
203
+ class="flex items-center justify-between px-6 py-4 gap-4 flex-wrap shrink-0"
204
+ style={{
205
+ background: "linear-gradient(135deg, #1a1a2e, #16213e)",
206
+ borderBottom: "1px solid #1e293b",
207
+ }}
208
+ >
209
+ <div
210
+ class="flex items-center gap-2.5 text-lg font-bold uppercase tracking-wider"
211
+ style={{ color: "#f1f5f9" }}
212
+ >
213
+ <span style={{ color: "#3b82f6" }}>&#9670;</span>
214
+ Dispatch Command Center
215
+ <span
216
+ class="w-2.5 h-2.5 rounded-full inline-block"
217
+ style={{
218
+ background: stateColor(state),
219
+ animation: state === "listening"
220
+ ? "dc-pulse 1.5s ease-in-out infinite"
221
+ : state === "thinking"
222
+ ? "dc-pulse 0.8s ease-in-out infinite"
223
+ : "none",
224
+ }}
225
+ title={state}
226
+ />
227
+ <span
228
+ class="text-[11px] font-normal normal-case"
229
+ style={{ color: "#64748b" }}
230
+ >
231
+ {state === "listening"
232
+ ? "LISTENING"
233
+ : state === "thinking"
234
+ ? "PROCESSING"
235
+ : state === "speaking"
236
+ ? "TRANSMITTING"
237
+ : state.toUpperCase()}
238
+ </span>
239
+ </div>
240
+ <div class="flex gap-2 items-center">
241
+ <span
242
+ class="text-[10px] tracking-wider"
243
+ style={{ color: "#64748b" }}
244
+ >
245
+ SYSTEM ALERT:
246
+ </span>
247
+ <span
248
+ class="px-3 py-1 rounded text-[11px] font-bold uppercase tracking-wider"
249
+ style={{
250
+ background: alertBg,
251
+ color: alertTextColor,
252
+ animation: alertLevel === "red"
253
+ ? "dc-pulse 1s ease-in-out infinite"
254
+ : "none",
255
+ }}
256
+ >
257
+ {alertLevel.toUpperCase()}
258
+ </span>
259
+ </div>
260
+ </div>
261
+
262
+ {/* Main content */}
263
+ <div
264
+ class="dc-main flex-1 grid overflow-hidden"
265
+ style={{ gridTemplateColumns: "1fr 320px" }}
266
+ >
267
+ {/* Left: conversation feed */}
268
+ <div
269
+ class="flex flex-col overflow-hidden"
270
+ style={{ borderRight: "1px solid #1e293b" }}
271
+ >
272
+ <div class="dc-messages flex-1 overflow-y-auto p-4 flex flex-col gap-2">
273
+ {msgs.length === 0 && (
274
+ <div
275
+ class="text-center p-10 text-[13px]"
276
+ style={{ color: "#475569" }}
277
+ >
278
+ Dispatch Command Center standing by. Click START to begin
279
+ operations.
280
+ </div>
281
+ )}
282
+ {msgs.map((m: Message, i: number) => (
283
+ <div
284
+ key={i}
285
+ class="rounded-lg text-[13px] max-w-[85%] px-3.5 py-2.5"
286
+ style={{
287
+ lineHeight: 1.6,
288
+ alignSelf: m.role === "assistant"
289
+ ? "flex-start"
290
+ : "flex-end",
291
+ background: m.role === "assistant" ? "#1e293b" : "#172554",
292
+ animation: "dc-slide-in 0.2s ease-out",
293
+ borderLeft: m.role === "assistant"
294
+ ? "3px solid #3b82f6"
295
+ : "none",
296
+ borderRight: m.role !== "assistant"
297
+ ? "3px solid #22d3ee"
298
+ : "none",
299
+ }}
300
+ >
301
+ <div
302
+ class="text-[10px] uppercase tracking-wider mb-1"
303
+ style={{ color: "#64748b" }}
304
+ >
305
+ {m.role === "assistant" ? "DISPATCH" : "OPERATOR"}
306
+ </div>
307
+ {m.content}
308
+ </div>
309
+ ))}
310
+ <div ref={messagesEndRef} />
311
+ </div>
312
+
313
+ {tx !== null && (
314
+ <div
315
+ class="flex items-center px-4 py-2 text-xs italic min-h-8"
316
+ style={{
317
+ background: "#111827",
318
+ borderTop: "1px solid #1e293b",
319
+ color: "#64748b",
320
+ }}
321
+ >
322
+ <span
323
+ class="w-2.5 h-2.5 rounded-full inline-block mr-2"
324
+ style={{
325
+ background: "#22c55e",
326
+ animation: "dc-pulse 1.5s ease-in-out infinite",
327
+ }}
328
+ />
329
+ {tx || "..."}
330
+ </div>
331
+ )}
332
+ {error && (
333
+ <div
334
+ class="px-4 py-2 text-xs"
335
+ style={{
336
+ background: "#450a0a",
337
+ color: "#fca5a5",
338
+ borderTop: "1px solid #991b1b",
339
+ }}
340
+ >
341
+ ERROR: {error.message} ({error.code})
342
+ </div>
343
+ )}
344
+
345
+ <div
346
+ class="flex items-center gap-2.5 px-4 py-3"
347
+ style={{ background: "#111827", borderTop: "1px solid #1e293b" }}
348
+ >
349
+ {!ctrl.started.value
350
+ ? (
351
+ <button
352
+ type="button"
353
+ class="px-4 py-2 border-none rounded-md font-mono text-xs font-semibold uppercase tracking-wider cursor-pointer text-white"
354
+ style={{ background: "#2563eb" }}
355
+ onClick={() => ctrl.start()}
356
+ >
357
+ Start Dispatch
358
+ </button>
359
+ )
360
+ : (
361
+ <>
362
+ <button
363
+ type="button"
364
+ class="px-4 py-2 border-none rounded-md font-mono text-xs font-semibold uppercase tracking-wider cursor-pointer"
365
+ style={{
366
+ background: ctrl.running.value ? "#334155" : "#2563eb",
367
+ color: ctrl.running.value ? "#e2e8f0" : "white",
368
+ }}
369
+ onClick={() => ctrl.toggle()}
370
+ >
371
+ {ctrl.running.value ? "Pause" : "Resume"}
372
+ </button>
373
+ <button
374
+ type="button"
375
+ class="px-4 py-2 border-none rounded-md font-mono text-xs font-semibold uppercase tracking-wider cursor-pointer text-white"
376
+ style={{ background: "#dc2626" }}
377
+ onClick={() => ctrl.reset()}
378
+ >
379
+ Reset
380
+ </button>
381
+ </>
382
+ )}
383
+ <div class="flex-1" />
384
+ <span class="text-[10px]" style={{ color: "#475569" }}>
385
+ {incidentList.length} incident
386
+ {incidentList.length !== 1 ? "s" : ""} logged
387
+ </span>
388
+ </div>
389
+ </div>
390
+
391
+ {/* Right: sidebar dashboard */}
392
+ <div
393
+ class="dc-sidebar overflow-y-auto p-4 flex flex-col gap-4"
394
+ style={{ background: "#111827" }}
395
+ >
396
+ <Panel title="Operations Summary">
397
+ <StatRow
398
+ label="Active Incidents"
399
+ value={activeIncidents.length}
400
+ color={activeIncidents.length > 3 ? "#ef4444" : "#e2e8f0"}
401
+ />
402
+ <StatRow label="Resolved" value={resolvedCount} color="#22c55e" />
403
+ <StatRow label="Total Logged" value={incidentList.length} />
404
+ </Panel>
405
+
406
+ <Panel title="Active Incidents">
407
+ {activeIncidents.length === 0
408
+ ? (
409
+ <div
410
+ class="text-xs text-center py-2"
411
+ style={{ color: "#475569" }}
412
+ >
413
+ No active incidents
414
+ </div>
415
+ )
416
+ : activeIncidents.map((inc) => (
417
+ <div
418
+ key={inc.id}
419
+ class="rounded-md p-2.5 mb-2"
420
+ style={{
421
+ background: "#0f172a",
422
+ animation: "dc-slide-in 0.3s ease-out",
423
+ border: `1px solid ${
424
+ severityColors[inc.severity ?? ""] || "#334155"
425
+ }40`,
426
+ borderLeft: `3px solid ${
427
+ severityColors[inc.severity ?? ""] || "#334155"
428
+ }`,
429
+ }}
430
+ >
431
+ <div class="flex justify-between items-center mb-1">
432
+ <span
433
+ class="text-xs font-bold"
434
+ style={{ color: "#f1f5f9" }}
435
+ >
436
+ {inc.id}
437
+ </span>
438
+ {inc.severity && (
439
+ <span
440
+ class="text-[9px] px-1.5 py-0.5 rounded font-bold uppercase"
441
+ style={{
442
+ background: `${
443
+ severityColors[inc.severity ?? ""]
444
+ }30`,
445
+ color: severityColors[inc.severity ?? ""],
446
+ }}
447
+ >
448
+ {inc.severity}
449
+ </span>
450
+ )}
451
+ </div>
452
+ {inc.location && (
453
+ <div
454
+ class="text-[11px] mb-0.5"
455
+ style={{ color: "#94a3b8" }}
456
+ >
457
+ {inc.location}
458
+ </div>
459
+ )}
460
+ {inc.status && (
461
+ <div
462
+ class="text-[10px] uppercase tracking-wider"
463
+ style={{ color: statusColors[inc.status] || "#6b7280" }}
464
+ >
465
+ {inc.status.replace("_", " ")}
466
+ </div>
467
+ )}
468
+ </div>
469
+ ))}
470
+ </Panel>
471
+
472
+ <Panel title="Severity Legend">
473
+ {Object.entries(severityColors).map(([sev, color]) => (
474
+ <div key={sev} class="flex items-center gap-2 py-0.5">
475
+ <span
476
+ class="w-2.5 h-2.5 rounded-sm"
477
+ style={{ background: color }}
478
+ />
479
+ <span
480
+ class="text-[11px] capitalize"
481
+ style={{ color: "#94a3b8" }}
482
+ >
483
+ {sev}
484
+ </span>
485
+ </div>
486
+ ))}
487
+ </Panel>
488
+
489
+ <Panel title="Training Scenarios">
490
+ <div
491
+ class="text-[11px] leading-relaxed"
492
+ style={{ color: "#64748b" }}
493
+ >
494
+ Say "run mass casualty scenario" or "simulate active shooter" to
495
+ test dispatch operations with complex multi-incident drills.
496
+ </div>
497
+ </Panel>
498
+ </div>
499
+ </div>
500
+ </div>
501
+ </>
502
+ );
503
+ }
504
+
505
+ mount(App);
@@ -0,0 +1,48 @@
1
+ import { defineAgent, tool } from "@alexkroman1/aai";
2
+ import { z } from "zod";
3
+ import knowledge from "./knowledge.json" with { type: "json" };
4
+
5
+ type FaqEntry = { question: string; answer: string };
6
+ const faqs: FaqEntry[] = knowledge.faqs;
7
+
8
+ export default defineAgent({
9
+ name: "FAQ Bot",
10
+ instructions:
11
+ `You are a friendly FAQ assistant. Answer questions using ONLY the information \
12
+ from your embedded knowledge base. If the user asks something not covered by your \
13
+ knowledge base, say you don't have that information and suggest they check the official \
14
+ documentation.
15
+
16
+ Rules:
17
+ - Keep answers concise and conversational — this is a voice agent
18
+ - Quote the knowledge base accurately, do not embellish
19
+ - If a question is ambiguous, ask the user to clarify
20
+ - Use 'search_knowledge' to find answers to specific questions
21
+ - Use 'list_topics' to see all available FAQ topics
22
+ - Always be helpful and polite`,
23
+ greeting:
24
+ "Hi! I'm your FAQ assistant. Ask me anything about the AAI agent framework and I'll look it up in my knowledge base.",
25
+ tools: {
26
+ search_knowledge: tool({
27
+ description:
28
+ "Search the embedded FAQ knowledge base for an answer matching the user's question.",
29
+ parameters: z.object({
30
+ query: z.string().describe("The user's question to search for"),
31
+ }),
32
+ execute: ({ query }) => {
33
+ const q = query.toLowerCase();
34
+ const match = faqs.find((f) =>
35
+ f.question.toLowerCase().includes(q) ||
36
+ q.includes(f.question.toLowerCase()) ||
37
+ f.answer.toLowerCase().includes(q)
38
+ );
39
+ return match ?? { result: "No matching FAQ found." };
40
+ },
41
+ }),
42
+ list_topics: {
43
+ description:
44
+ "List all available topics in the embedded FAQ knowledge base.",
45
+ execute: () => faqs.map((f) => f.question),
46
+ },
47
+ },
48
+ });
@@ -0,0 +1,3 @@
1
+ import "@alexkroman1/aai-ui/styles.css";
2
+ import { App, mount } from "@alexkroman1/aai-ui";
3
+ mount(App);
@@ -0,0 +1,20 @@
1
+ {
2
+ "faqs": [
3
+ {
4
+ "question": "What is AAI?",
5
+ "answer": "AAI is a framework for building voice-powered AI agents that run on Node.js."
6
+ },
7
+ {
8
+ "question": "How do agents handle tools?",
9
+ "answer": "Agents define tools with a schema and handler function. The LLM decides when to call them based on the conversation."
10
+ },
11
+ {
12
+ "question": "What speech providers are supported?",
13
+ "answer": "AAI supports AssemblyAI for speech-to-text and text-to-speech."
14
+ },
15
+ {
16
+ "question": "Can agents access the internet?",
17
+ "answer": "Yes, agents run with network access and can make HTTP requests via the fetch API or built-in tools like web_search."
18
+ }
19
+ ]
20
+ }