@alexkroman1/aai 0.3.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 (79) hide show
  1. package/LICENSE +21 -0
  2. package/dist/cli.js +3436 -0
  3. package/package.json +78 -0
  4. package/sdk/_internal_types.ts +89 -0
  5. package/sdk/_mock_ws.ts +172 -0
  6. package/sdk/_timeout.ts +24 -0
  7. package/sdk/builtin_tools.ts +309 -0
  8. package/sdk/capnweb.ts +341 -0
  9. package/sdk/define_agent.ts +70 -0
  10. package/sdk/direct_executor.ts +195 -0
  11. package/sdk/kv.ts +183 -0
  12. package/sdk/mod.ts +35 -0
  13. package/sdk/protocol.ts +313 -0
  14. package/sdk/runtime.ts +65 -0
  15. package/sdk/s2s.ts +271 -0
  16. package/sdk/server.ts +198 -0
  17. package/sdk/session.ts +438 -0
  18. package/sdk/system_prompt.ts +47 -0
  19. package/sdk/types.ts +406 -0
  20. package/sdk/vector.ts +133 -0
  21. package/sdk/winterc_server.ts +141 -0
  22. package/sdk/worker_entry.ts +99 -0
  23. package/sdk/worker_shim.ts +170 -0
  24. package/sdk/ws_handler.ts +190 -0
  25. package/templates/_shared/.env.example +5 -0
  26. package/templates/_shared/package.json +17 -0
  27. package/templates/code-interpreter/agent.ts +27 -0
  28. package/templates/code-interpreter/client.tsx +2 -0
  29. package/templates/dispatch-center/agent.ts +1536 -0
  30. package/templates/dispatch-center/client.tsx +504 -0
  31. package/templates/embedded-assets/agent.ts +49 -0
  32. package/templates/embedded-assets/client.tsx +2 -0
  33. package/templates/embedded-assets/knowledge.json +20 -0
  34. package/templates/health-assistant/agent.ts +160 -0
  35. package/templates/health-assistant/client.tsx +2 -0
  36. package/templates/infocom-adventure/agent.ts +164 -0
  37. package/templates/infocom-adventure/client.tsx +299 -0
  38. package/templates/math-buddy/agent.ts +21 -0
  39. package/templates/math-buddy/client.tsx +2 -0
  40. package/templates/memory-agent/agent.ts +74 -0
  41. package/templates/memory-agent/client.tsx +2 -0
  42. package/templates/night-owl/agent.ts +98 -0
  43. package/templates/night-owl/client.tsx +28 -0
  44. package/templates/personal-finance/agent.ts +26 -0
  45. package/templates/personal-finance/client.tsx +2 -0
  46. package/templates/simple/agent.ts +6 -0
  47. package/templates/simple/client.tsx +2 -0
  48. package/templates/smart-research/agent.ts +164 -0
  49. package/templates/smart-research/client.tsx +2 -0
  50. package/templates/support/README.md +62 -0
  51. package/templates/support/agent.ts +19 -0
  52. package/templates/support/client.tsx +2 -0
  53. package/templates/travel-concierge/agent.ts +29 -0
  54. package/templates/travel-concierge/client.tsx +2 -0
  55. package/templates/web-researcher/agent.ts +17 -0
  56. package/templates/web-researcher/client.tsx +2 -0
  57. package/ui/_components/app.tsx +37 -0
  58. package/ui/_components/chat_view.tsx +36 -0
  59. package/ui/_components/controls.tsx +32 -0
  60. package/ui/_components/error_banner.tsx +18 -0
  61. package/ui/_components/message_bubble.tsx +21 -0
  62. package/ui/_components/message_list.tsx +61 -0
  63. package/ui/_components/state_indicator.tsx +17 -0
  64. package/ui/_components/thinking_indicator.tsx +19 -0
  65. package/ui/_components/tool_call_block.tsx +110 -0
  66. package/ui/_components/tool_icons.tsx +101 -0
  67. package/ui/_components/transcript.tsx +20 -0
  68. package/ui/audio.ts +170 -0
  69. package/ui/components.ts +49 -0
  70. package/ui/components_mod.ts +37 -0
  71. package/ui/mod.ts +48 -0
  72. package/ui/mount.tsx +112 -0
  73. package/ui/mount_context.ts +19 -0
  74. package/ui/session.ts +456 -0
  75. package/ui/session_mod.ts +27 -0
  76. package/ui/signals.ts +111 -0
  77. package/ui/types.ts +50 -0
  78. package/ui/worklets/capture-processor.js +62 -0
  79. package/ui/worklets/playback-processor.js +110 -0
@@ -0,0 +1,1536 @@
1
+ import { defineAgent } from "@alexkroman1/aai";
2
+ import { z } from "zod";
3
+ import type { HookContext, ToolContext } from "@alexkroman1/aai";
4
+
5
+ // ─── Types ───────────────────────────────────────────────────────────────────
6
+
7
+ type Severity = "critical" | "urgent" | "moderate" | "minor";
8
+ type IncidentType =
9
+ | "medical"
10
+ | "fire"
11
+ | "hazmat"
12
+ | "traffic"
13
+ | "crime"
14
+ | "natural_disaster"
15
+ | "utility"
16
+ | "other";
17
+ type Status =
18
+ | "incoming"
19
+ | "triaged"
20
+ | "dispatched"
21
+ | "en_route"
22
+ | "on_scene"
23
+ | "resolved"
24
+ | "escalated";
25
+
26
+ interface Resource {
27
+ id: string;
28
+ type:
29
+ | "ambulance"
30
+ | "fire_engine"
31
+ | "police"
32
+ | "hazmat_team"
33
+ | "helicopter"
34
+ | "k9_unit"
35
+ | "swat"
36
+ | "ems_supervisor";
37
+ callsign: string;
38
+ status: "available" | "dispatched" | "en_route" | "on_scene" | "returning";
39
+ assignedIncident: string | null;
40
+ eta: number | null; // minutes
41
+ capabilities: string[];
42
+ }
43
+
44
+ interface Incident {
45
+ id: string;
46
+ type: IncidentType;
47
+ severity: Severity;
48
+ status: Status;
49
+ location: string;
50
+ description: string;
51
+ callerName: string;
52
+ callerPhone: string;
53
+ triageScore: number;
54
+ assignedResources: string[];
55
+ timeline: { time: number; event: string }[];
56
+ notes: string[];
57
+ createdAt: number;
58
+ updatedAt: number;
59
+ escalationLevel: number;
60
+ protocolsActivated: string[];
61
+ casualties: { confirmed: number; estimated: number; treated: number };
62
+ hazards: string[];
63
+ }
64
+
65
+ interface DispatchState {
66
+ incidents: Record<string, Incident>;
67
+ resources: Resource[];
68
+ incidentCounter: number;
69
+ alertLevel: "green" | "yellow" | "orange" | "red"; // system-wide
70
+ mutualAidRequested: boolean;
71
+ }
72
+
73
+ // ─── Session state ───────────────────────────────────────────────────────────
74
+
75
+ function createState(): DispatchState {
76
+ return {
77
+ incidents: {},
78
+ resources: generateResources(),
79
+ incidentCounter: 0,
80
+ alertLevel: "green",
81
+ mutualAidRequested: false,
82
+ };
83
+ }
84
+
85
+ function generateResources(): Resource[] {
86
+ return [
87
+ {
88
+ id: "R1",
89
+ type: "ambulance",
90
+ callsign: "Medic-1",
91
+ status: "available",
92
+ assignedIncident: null,
93
+ eta: null,
94
+ capabilities: ["als", "cardiac", "pediatric"],
95
+ },
96
+ {
97
+ id: "R2",
98
+ type: "ambulance",
99
+ callsign: "Medic-2",
100
+ status: "available",
101
+ assignedIncident: null,
102
+ eta: null,
103
+ capabilities: ["als", "trauma"],
104
+ },
105
+ {
106
+ id: "R3",
107
+ type: "ambulance",
108
+ callsign: "Medic-3",
109
+ status: "available",
110
+ assignedIncident: null,
111
+ eta: null,
112
+ capabilities: ["bls"],
113
+ },
114
+ {
115
+ id: "R4",
116
+ type: "fire_engine",
117
+ callsign: "Engine-7",
118
+ status: "available",
119
+ assignedIncident: null,
120
+ eta: null,
121
+ capabilities: ["structural", "rescue", "ems_first_response"],
122
+ },
123
+ {
124
+ id: "R5",
125
+ type: "fire_engine",
126
+ callsign: "Ladder-2",
127
+ status: "available",
128
+ assignedIncident: null,
129
+ eta: null,
130
+ capabilities: ["aerial", "rescue", "ventilation"],
131
+ },
132
+ {
133
+ id: "R6",
134
+ type: "police",
135
+ callsign: "Unit-12",
136
+ status: "available",
137
+ assignedIncident: null,
138
+ eta: null,
139
+ capabilities: ["patrol", "traffic_control"],
140
+ },
141
+ {
142
+ id: "R7",
143
+ type: "police",
144
+ callsign: "Unit-15",
145
+ status: "available",
146
+ assignedIncident: null,
147
+ eta: null,
148
+ capabilities: ["patrol", "investigation"],
149
+ },
150
+ {
151
+ id: "R8",
152
+ type: "hazmat_team",
153
+ callsign: "HazMat-1",
154
+ status: "available",
155
+ assignedIncident: null,
156
+ eta: null,
157
+ capabilities: ["chemical", "biological", "radiological", "decon"],
158
+ },
159
+ {
160
+ id: "R9",
161
+ type: "helicopter",
162
+ callsign: "LifeFlight-1",
163
+ status: "available",
164
+ assignedIncident: null,
165
+ eta: null,
166
+ capabilities: ["medevac", "search_rescue", "thermal_imaging"],
167
+ },
168
+ {
169
+ id: "R10",
170
+ type: "ems_supervisor",
171
+ callsign: "EMS-Sup-1",
172
+ status: "available",
173
+ assignedIncident: null,
174
+ eta: null,
175
+ capabilities: ["mass_casualty", "triage_lead", "command"],
176
+ },
177
+ {
178
+ id: "R11",
179
+ type: "k9_unit",
180
+ callsign: "K9-3",
181
+ status: "available",
182
+ assignedIncident: null,
183
+ eta: null,
184
+ capabilities: ["tracking", "narcotics", "explosives"],
185
+ },
186
+ {
187
+ id: "R12",
188
+ type: "swat",
189
+ callsign: "TAC-1",
190
+ status: "available",
191
+ assignedIncident: null,
192
+ eta: null,
193
+ capabilities: ["tactical", "hostage_rescue", "high_risk_warrant"],
194
+ },
195
+ ];
196
+ }
197
+
198
+ // ─── Triage & scoring ────────────────────────────────────────────────────────
199
+
200
+ const SEVERITY_WEIGHTS: Record<Severity, number> = {
201
+ critical: 100,
202
+ urgent: 70,
203
+ moderate: 40,
204
+ minor: 10,
205
+ };
206
+
207
+ const TYPE_MULTIPLIERS: Record<IncidentType, number> = {
208
+ medical: 1.2,
209
+ fire: 1.3,
210
+ hazmat: 1.5,
211
+ traffic: 1.0,
212
+ crime: 1.1,
213
+ "natural_disaster": 1.8,
214
+ utility: 0.8,
215
+ other: 0.7,
216
+ };
217
+
218
+ function calculateTriageScore(
219
+ severity: Severity,
220
+ type: IncidentType,
221
+ casualties: number,
222
+ hazards: number,
223
+ ): number {
224
+ let score = SEVERITY_WEIGHTS[severity] * TYPE_MULTIPLIERS[type];
225
+ score += Math.min(casualties * 15, 60); // up to 60 pts for casualties
226
+ score += Math.min(hazards * 10, 30); // up to 30 pts for hazards
227
+ return Math.round(Math.min(score, 250));
228
+ }
229
+
230
+ function recommendSeverity(description: string): Severity {
231
+ const d = description.toLowerCase();
232
+ const criticalKeywords = [
233
+ "unconscious",
234
+ "not breathing",
235
+ "cardiac arrest",
236
+ "trapped",
237
+ "collapse",
238
+ "explosion",
239
+ "active shooter",
240
+ "mass casualty",
241
+ ];
242
+ const urgentKeywords = [
243
+ "bleeding",
244
+ "chest pain",
245
+ "difficulty breathing",
246
+ "fire",
247
+ "hazmat",
248
+ "shooting",
249
+ "stabbing",
250
+ "multi-vehicle",
251
+ ];
252
+ const moderateKeywords = [
253
+ "fall",
254
+ "broken",
255
+ "fracture",
256
+ "smoke",
257
+ "minor fire",
258
+ "assault",
259
+ "theft",
260
+ ];
261
+
262
+ if (criticalKeywords.some((k) => d.includes(k))) return "critical";
263
+ if (urgentKeywords.some((k) => d.includes(k))) return "urgent";
264
+ if (moderateKeywords.some((k) => d.includes(k))) return "moderate";
265
+ return "minor";
266
+ }
267
+
268
+ function recommendType(description: string): IncidentType {
269
+ const d = description.toLowerCase();
270
+ const typeKeywords: Record<IncidentType, string[]> = {
271
+ medical: [
272
+ "chest pain",
273
+ "breathing",
274
+ "unconscious",
275
+ "seizure",
276
+ "allergic",
277
+ "overdose",
278
+ "cardiac",
279
+ "stroke",
280
+ "diabetic",
281
+ "bleeding",
282
+ "fall",
283
+ "injury",
284
+ ],
285
+ fire: ["fire", "smoke", "flames", "burning", "arson"],
286
+ hazmat: [
287
+ "chemical",
288
+ "spill",
289
+ "gas leak",
290
+ "fumes",
291
+ "radiation",
292
+ "contamination",
293
+ "hazmat",
294
+ ],
295
+ traffic: [
296
+ "accident",
297
+ "crash",
298
+ "collision",
299
+ "vehicle",
300
+ "rollover",
301
+ "pedestrian struck",
302
+ "hit and run",
303
+ ],
304
+ crime: [
305
+ "robbery",
306
+ "assault",
307
+ "shooting",
308
+ "stabbing",
309
+ "burglar",
310
+ "theft",
311
+ "domestic",
312
+ "hostage",
313
+ "active shooter",
314
+ ],
315
+ "natural_disaster": [
316
+ "earthquake",
317
+ "flood",
318
+ "tornado",
319
+ "hurricane",
320
+ "landslide",
321
+ "wildfire",
322
+ "tsunami",
323
+ ],
324
+ utility: [
325
+ "power outage",
326
+ "downed line",
327
+ "water main",
328
+ "gas main",
329
+ "transformer",
330
+ ],
331
+ other: [],
332
+ };
333
+
334
+ let best: IncidentType = "other";
335
+ let bestCount = 0;
336
+ for (const [type, keywords] of Object.entries(typeKeywords)) {
337
+ const count = keywords.filter((k) => d.includes(k)).length;
338
+ if (count > bestCount) {
339
+ bestCount = count;
340
+ best = type as IncidentType;
341
+ }
342
+ }
343
+ return best;
344
+ }
345
+
346
+ // ─── Protocol engine ─────────────────────────────────────────────────────────
347
+
348
+ interface Protocol {
349
+ name: string;
350
+ triggers: { types: IncidentType[]; minSeverity: Severity };
351
+ steps: string[];
352
+ requiredResources: Resource["type"][];
353
+ }
354
+
355
+ const PROTOCOLS: Protocol[] = [
356
+ {
357
+ name: "Mass Casualty Incident (MCI)",
358
+ triggers: {
359
+ types: ["medical", "fire", "natural_disaster", "traffic"],
360
+ minSeverity: "critical",
361
+ },
362
+ steps: [
363
+ "Establish Incident Command",
364
+ "Request mutual aid if more than 10 casualties estimated",
365
+ "Set up triage area using START protocol: Immediate (red), Delayed (yellow), Minor (green), Deceased (black)",
366
+ "Assign triage lead (EMS supervisor)",
367
+ "Establish patient collection point and treatment area",
368
+ "Coordinate helicopter landing zone if needed",
369
+ "Notify receiving hospitals and activate surge protocols",
370
+ ],
371
+ requiredResources: ["ambulance", "ems_supervisor", "fire_engine"],
372
+ },
373
+ {
374
+ name: "Structure Fire - Working Fire",
375
+ triggers: { types: ["fire"], minSeverity: "urgent" },
376
+ steps: [
377
+ "Dispatch minimum 2 engines and 1 ladder",
378
+ "Establish incident command and 360-degree size-up",
379
+ "Confirm water supply — nearest hydrant",
380
+ "Search and rescue primary sweep",
381
+ "Ventilation operations",
382
+ "Establish RIT (Rapid Intervention Team)",
383
+ "Request additional alarms if fire is not contained in 10 minutes",
384
+ ],
385
+ requiredResources: ["fire_engine"],
386
+ },
387
+ {
388
+ name: "Hazardous Materials Response",
389
+ triggers: { types: ["hazmat"], minSeverity: "moderate" },
390
+ steps: [
391
+ "Identify the substance using placard numbers or SDS if available",
392
+ "Establish hot, warm, and cold zones",
393
+ "Evacuate downwind at minimum 1000 feet for unknown substances",
394
+ "Deploy HazMat team in appropriate PPE level",
395
+ "Set up decontamination corridor",
396
+ "Monitor air quality and wind direction continuously",
397
+ "Coordinate with poison control and environmental agency",
398
+ ],
399
+ requiredResources: ["hazmat_team", "fire_engine", "ambulance"],
400
+ },
401
+ {
402
+ name: "Active Threat / Active Shooter",
403
+ triggers: { types: ["crime"], minSeverity: "critical" },
404
+ steps: [
405
+ "Dispatch SWAT and multiple patrol units",
406
+ "Establish inner and outer perimeters",
407
+ "Activate Rescue Task Force protocol — police escort for EMS into warm zone",
408
+ "Stage ambulances at casualty collection point outside hot zone",
409
+ "Request LifeFlight on standby",
410
+ "Coordinate with school/building security for floor plans",
411
+ "Establish family reunification point",
412
+ ],
413
+ requiredResources: ["swat", "police", "ambulance", "ems_supervisor"],
414
+ },
415
+ {
416
+ name: "Multi-Vehicle Accident",
417
+ triggers: { types: ["traffic"], minSeverity: "urgent" },
418
+ steps: [
419
+ "Dispatch engine company for extrication capability",
420
+ "Request traffic control units to shut down lanes",
421
+ "Triage patients using START protocol",
422
+ "Check for fuel or hazmat spills",
423
+ "Establish landing zone if helicopter transport needed",
424
+ "Coordinate with DOT for road closures and detours",
425
+ ],
426
+ requiredResources: ["fire_engine", "ambulance", "police"],
427
+ },
428
+ {
429
+ name: "Cardiac Arrest Protocol",
430
+ triggers: { types: ["medical"], minSeverity: "critical" },
431
+ steps: [
432
+ "Instruct caller to begin CPR immediately — 30 compressions, 2 breaths",
433
+ "Dispatch closest ALS unit and fire engine for first response",
434
+ "Guide caller through AED use if available",
435
+ "Time from call to first defibrillation is critical — target under 8 minutes",
436
+ "Prepare for advanced airway management on arrival",
437
+ ],
438
+ requiredResources: ["ambulance", "fire_engine"],
439
+ },
440
+ ];
441
+
442
+ function getApplicableProtocols(
443
+ type: IncidentType,
444
+ severity: Severity,
445
+ ): Protocol[] {
446
+ const severityRank: Record<Severity, number> = {
447
+ critical: 4,
448
+ urgent: 3,
449
+ moderate: 2,
450
+ minor: 1,
451
+ };
452
+ return PROTOCOLS.filter((p) =>
453
+ p.triggers.types.includes(type) &&
454
+ severityRank[severity] >= severityRank[p.triggers.minSeverity]
455
+ );
456
+ }
457
+
458
+ // ─── Resource recommendation engine ──────────────────────────────────────────
459
+
460
+ function recommendResources(
461
+ type: IncidentType,
462
+ severity: Severity,
463
+ state: DispatchState,
464
+ ): Resource[] {
465
+ const needed: Resource["type"][] = [];
466
+
467
+ // Base resource needs by incident type
468
+ const baseNeeds: Record<IncidentType, Resource["type"][]> = {
469
+ medical: ["ambulance"],
470
+ fire: ["fire_engine", "ambulance"],
471
+ hazmat: ["hazmat_team", "fire_engine", "ambulance"],
472
+ traffic: ["police", "ambulance", "fire_engine"],
473
+ crime: ["police"],
474
+ "natural_disaster": ["fire_engine", "ambulance", "police"],
475
+ utility: ["fire_engine"],
476
+ other: [],
477
+ };
478
+
479
+ needed.push(...(baseNeeds[type] || []));
480
+
481
+ // Severity escalation
482
+ if (severity === "critical") {
483
+ if (!needed.includes("ambulance")) needed.push("ambulance");
484
+ needed.push("ems_supervisor");
485
+ if (type === "crime") needed.push("swat");
486
+ }
487
+ if (severity === "urgent" && type === "fire") {
488
+ needed.push("fire_engine"); // second engine
489
+ }
490
+
491
+ // Find available resources matching needs
492
+ const recommended: Resource[] = [];
493
+ const usedIds = new Set<string>();
494
+
495
+ for (const needType of needed) {
496
+ const available = state.resources.find(
497
+ (r) =>
498
+ r.type === needType && r.status === "available" && !usedIds.has(r.id),
499
+ );
500
+ if (available) {
501
+ recommended.push(available);
502
+ usedIds.add(available.id);
503
+ }
504
+ }
505
+
506
+ return recommended;
507
+ }
508
+
509
+ // ─── System alert level calculation ──────────────────────────────────────────
510
+
511
+ function recalculateAlertLevel(state: DispatchState): void {
512
+ const activeIncidents = Object.values(state.incidents).filter((i) =>
513
+ !["resolved"].includes(i.status)
514
+ );
515
+ const criticalCount =
516
+ activeIncidents.filter((i) => i.severity === "critical").length;
517
+ const totalActive = activeIncidents.length;
518
+ const availableResources =
519
+ state.resources.filter((r) => r.status === "available").length;
520
+ const totalResources = state.resources.length;
521
+ const resourceUtilization = 1 - (availableResources / totalResources);
522
+
523
+ if (criticalCount >= 3 || resourceUtilization > 0.85 || totalActive >= 8) {
524
+ state.alertLevel = "red";
525
+ } else if (
526
+ criticalCount >= 2 || resourceUtilization > 0.65 || totalActive >= 5
527
+ ) {
528
+ state.alertLevel = "orange";
529
+ } else if (
530
+ criticalCount >= 1 || resourceUtilization > 0.4 || totalActive >= 3
531
+ ) {
532
+ state.alertLevel = "yellow";
533
+ } else {
534
+ state.alertLevel = "green";
535
+ }
536
+
537
+ // Auto-request mutual aid at red
538
+ if (state.alertLevel === "red" && !state.mutualAidRequested) {
539
+ state.mutualAidRequested = true;
540
+ }
541
+ }
542
+
543
+ function now(): number {
544
+ return Date.now();
545
+ }
546
+
547
+ // ─── KV persistence ─────────────────────────────────────────────────────────
548
+
549
+ const STATE_KEY = "dispatch:state";
550
+
551
+ async function saveState(
552
+ ctx: { kv: ToolContext["kv"]; state: unknown },
553
+ ): Promise<void> {
554
+ await ctx.kv.set(STATE_KEY, ctx.state);
555
+ }
556
+
557
+ async function loadState(ctx: HookContext<DispatchState>): Promise<void> {
558
+ const saved = await ctx.kv.get<DispatchState>(STATE_KEY);
559
+ if (saved) {
560
+ Object.assign(ctx.state, saved);
561
+ }
562
+ }
563
+
564
+ // ─── Agent definition ────────────────────────────────────────────────────────
565
+
566
+ export default defineAgent({
567
+ name: "Dispatch Command Center",
568
+ transport: ["websocket"],
569
+
570
+ greeting:
571
+ "Dispatch Command Center online. Restoring operational state. I'm ready to take incoming calls, manage active incidents, or run dispatch operations. Say 'dashboard' for a full status report. What do we have.",
572
+
573
+ instructions:
574
+ `You are the AI-powered Emergency Dispatch Command Center. You coordinate emergency response for a metropolitan area. You manage incidents from initial 911 call through resolution.
575
+
576
+ Your role combines call-taker, dispatcher, and incident commander. You speak like an experienced dispatcher: calm, precise, and authoritative. Never panic. Use brevity codes and dispatch terminology naturally.
577
+
578
+ Your tools:
579
+
580
+ INCIDENT MANAGEMENT:
581
+ - incident_create: Log a new incident. Ask for location first, then nature of emergency, then caller info. Speed matters for critical calls.
582
+ - incident_triage: After creating, assess severity. The system recommends severity, type, and protocols. Review and confirm or override.
583
+ - incident_update_status: Move incidents through the workflow (en_route, on_scene, resolved, escalated).
584
+ - incident_get: Get details on a specific incident.
585
+ - incident_escalate: Escalate when an incident exceeds current capacity or severity increases.
586
+ - incident_add_note: Add ongoing situational updates.
587
+
588
+ RESOURCE MANAGEMENT:
589
+ - resources_dispatch: Assign units. The system recommends optimal resources based on incident type and severity. You can also manually dispatch specific units.
590
+ - resources_get_available: See what units are free.
591
+ - resources_update_status: Update unit status when units radio in.
592
+
593
+ OPERATIONS:
594
+ - ops_dashboard: Get the full operational picture.
595
+ - ops_protocols: Retrieve step-by-step response protocols. Follow them precisely for critical incidents.
596
+ - ops_run_scenario: Run training exercises.
597
+
598
+ SEARCH: Use web_search to look up hazmat placard numbers, drug interactions, building addresses, or other reference information during active incidents.
599
+
600
+ CALCULATIONS: Use run_code for ETA calculations, resource optimization, or casualty estimates.
601
+
602
+ Operational rules:
603
+ - Location is always the first priority in any emergency call
604
+ - Critical incidents get immediate dispatch, triage can happen simultaneously
605
+ - Never leave a critical incident without at least one resource dispatched
606
+ - Monitor resource utilization. If it exceeds 65 percent, warn about degraded capacity
607
+ - At red alert level, recommend mutual aid from neighboring jurisdictions
608
+ - Track time on all incidents. Escalate if critical incidents have no on-scene resources within 8 minutes
609
+ - When reporting the dashboard, lead with the most severe active incidents
610
+ - Use plain language for medical instructions to callers, dispatch terminology for unit communications
611
+
612
+ Radio style: "Medic-1, respond priority one to 400 Oak Street, report of cardiac arrest, CPR in progress." Keep it tight and professional.`,
613
+
614
+ builtinTools: [],
615
+
616
+ state: createState,
617
+
618
+ onConnect: async (ctx) => {
619
+ await loadState(ctx);
620
+ },
621
+
622
+ tools: {
623
+ incident_create: {
624
+ description: "Create a new incident from an incoming emergency call.",
625
+ parameters: z.object({
626
+ location: z.string().describe("Address or location description"),
627
+ description: z.string().describe(
628
+ "Nature of the emergency as described by caller",
629
+ ),
630
+ callerName: z.string().describe("Caller's name").optional(),
631
+ callerPhone: z.string().describe("Callback number").optional(),
632
+ estimatedCasualties: z.number().describe(
633
+ "Estimated number of casualties if known",
634
+ ).optional(),
635
+ hazards: z.array(z.string()).describe(
636
+ "Known hazards: fire, chemical, electrical, structural, weapons",
637
+ ).optional(),
638
+ }),
639
+ execute: async (
640
+ {
641
+ location,
642
+ description,
643
+ callerName,
644
+ callerPhone,
645
+ estimatedCasualties,
646
+ hazards,
647
+ },
648
+ ctx,
649
+ ) => {
650
+ const state = ctx.state;
651
+ state.incidentCounter++;
652
+ const id = `INC-${String(state.incidentCounter).padStart(4, "0")}`;
653
+
654
+ const recSeverity = recommendSeverity(description);
655
+ const recType = recommendType(description);
656
+ const triageScore = calculateTriageScore(
657
+ recSeverity,
658
+ recType,
659
+ estimatedCasualties || 0,
660
+ hazards?.length || 0,
661
+ );
662
+
663
+ const incident: Incident = {
664
+ id,
665
+ type: recType,
666
+ severity: recSeverity,
667
+ status: "incoming",
668
+ location,
669
+ description,
670
+ callerName: callerName || "Unknown",
671
+ callerPhone: callerPhone || "Unknown",
672
+ triageScore,
673
+ assignedResources: [],
674
+ timeline: [{
675
+ time: now(),
676
+ event: `Incident created: ${description}`,
677
+ }],
678
+ notes: [],
679
+ createdAt: now(),
680
+ updatedAt: now(),
681
+ escalationLevel: 0,
682
+ protocolsActivated: [],
683
+ casualties: {
684
+ confirmed: 0,
685
+ estimated: estimatedCasualties || 0,
686
+ treated: 0,
687
+ },
688
+ hazards: hazards || [],
689
+ };
690
+
691
+ state.incidents[id] = incident;
692
+ recalculateAlertLevel(state);
693
+ await saveState(ctx);
694
+
695
+ const protocols = getApplicableProtocols(recType, recSeverity);
696
+ const recommended = recommendResources(
697
+ recType,
698
+ recSeverity,
699
+ state,
700
+ );
701
+
702
+ return {
703
+ incidentId: id,
704
+ recommendedSeverity: recSeverity,
705
+ recommendedType: recType,
706
+ triageScore,
707
+ applicableProtocols: protocols.map((p) => p.name),
708
+ recommendedResources: recommended.map((r) => ({
709
+ callsign: r.callsign,
710
+ type: r.type,
711
+ capabilities: r.capabilities,
712
+ })),
713
+ systemAlertLevel: state.alertLevel,
714
+ message: recSeverity === "critical"
715
+ ? `PRIORITY ONE — ${id} created. Immediate dispatch recommended. ${protocols.length} protocol(s) applicable.`
716
+ : `${id} created. Triage score ${triageScore}. ${recommended.length} resource(s) recommended.`,
717
+ };
718
+ },
719
+ },
720
+
721
+ incident_triage: {
722
+ description:
723
+ "Triage an incident — confirm or override severity, type, hazards, and casualty count.",
724
+ parameters: z.object({
725
+ incidentId: z.string().describe("The incident ID"),
726
+ severity: z.enum(["critical", "urgent", "moderate", "minor"])
727
+ .describe("Confirmed severity after triage").optional(),
728
+ type: z.enum([
729
+ "medical",
730
+ "fire",
731
+ "hazmat",
732
+ "traffic",
733
+ "crime",
734
+ "natural_disaster",
735
+ "utility",
736
+ "other",
737
+ ]).describe("Confirmed incident type").optional(),
738
+ additionalHazards: z.array(z.string()).describe(
739
+ "Any additional hazards identified",
740
+ ).optional(),
741
+ casualtyUpdate: z.number().describe("Updated casualty count")
742
+ .optional(),
743
+ notes: z.string().describe("Triage notes").optional(),
744
+ }),
745
+ execute: async (
746
+ {
747
+ incidentId,
748
+ severity,
749
+ type,
750
+ additionalHazards,
751
+ casualtyUpdate,
752
+ notes,
753
+ },
754
+ ctx,
755
+ ) => {
756
+ const state = ctx.state;
757
+ const inc = state.incidents[incidentId];
758
+ if (!inc) return { error: `Incident ${incidentId} not found` };
759
+
760
+ if (severity) inc.severity = severity;
761
+ if (type) inc.type = type;
762
+ if (additionalHazards) inc.hazards.push(...additionalHazards);
763
+ if (casualtyUpdate !== undefined) {
764
+ inc.casualties.estimated = casualtyUpdate;
765
+ }
766
+ if (notes) inc.notes.push(notes);
767
+
768
+ inc.triageScore = calculateTriageScore(
769
+ inc.severity,
770
+ inc.type,
771
+ inc.casualties.estimated,
772
+ inc.hazards.length,
773
+ );
774
+ inc.status = "triaged";
775
+ inc.updatedAt = now();
776
+ inc.timeline.push({
777
+ time: now(),
778
+ event:
779
+ `Triaged: ${inc.severity} ${inc.type}, score ${inc.triageScore}`,
780
+ });
781
+
782
+ recalculateAlertLevel(state);
783
+ await saveState(ctx);
784
+
785
+ const protocols = getApplicableProtocols(inc.type, inc.severity);
786
+ const recommended = recommendResources(
787
+ inc.type,
788
+ inc.severity,
789
+ state,
790
+ );
791
+
792
+ return {
793
+ incidentId,
794
+ severity: inc.severity,
795
+ type: inc.type,
796
+ triageScore: inc.triageScore,
797
+ hazards: inc.hazards,
798
+ estimatedCasualties: inc.casualties.estimated,
799
+ protocols: protocols.map((p) => ({
800
+ name: p.name,
801
+ steps: p.steps,
802
+ requiredResources: p.requiredResources,
803
+ })),
804
+ recommendedResources: recommended.map((r) => ({
805
+ callsign: r.callsign,
806
+ type: r.type,
807
+ })),
808
+ systemAlertLevel: state.alertLevel,
809
+ };
810
+ },
811
+ },
812
+
813
+ incident_update_status: {
814
+ description:
815
+ "Update an incident's status (en_route, on_scene, resolved, escalated).",
816
+ parameters: z.object({
817
+ incidentId: z.string().describe("The incident ID"),
818
+ status: z.enum(["en_route", "on_scene", "resolved", "escalated"])
819
+ .describe("New status"),
820
+ notes: z.string().describe("Status update notes").optional(),
821
+ casualtyUpdate: z.object({
822
+ confirmed: z.number().optional(),
823
+ treated: z.number().optional(),
824
+ }).describe("Updated casualty numbers").optional(),
825
+ }),
826
+ execute: async (
827
+ { incidentId, status, notes, casualtyUpdate },
828
+ ctx,
829
+ ) => {
830
+ const state = ctx.state;
831
+ const inc = state.incidents[incidentId];
832
+ if (!inc) return { error: `Incident ${incidentId} not found` };
833
+
834
+ inc.status = status;
835
+ inc.updatedAt = now();
836
+ inc.timeline.push({
837
+ time: now(),
838
+ event: `Status → ${status}${notes ? `: ${notes}` : ""}`,
839
+ });
840
+ if (notes) inc.notes.push(notes);
841
+
842
+ if (casualtyUpdate) {
843
+ if (casualtyUpdate.confirmed !== undefined) {
844
+ inc.casualties.confirmed = casualtyUpdate.confirmed;
845
+ }
846
+ if (casualtyUpdate.treated !== undefined) {
847
+ inc.casualties.treated = casualtyUpdate.treated;
848
+ }
849
+ }
850
+
851
+ // Release resources on resolution
852
+ if (status === "resolved") {
853
+ for (const rId of inc.assignedResources) {
854
+ const r = state.resources.find((r) => r.id === rId);
855
+ if (r) {
856
+ r.status = "returning";
857
+ r.assignedIncident = null;
858
+ r.eta = null;
859
+ // Auto-return to available after a delay (simulated)
860
+ setTimeout(() => {
861
+ r.status = "available";
862
+ }, 2000);
863
+ }
864
+ }
865
+ inc.timeline.push({
866
+ time: now(),
867
+ event: "All resources released — incident closed",
868
+ });
869
+ }
870
+
871
+ // Update resource statuses for en_route/on_scene
872
+ if (status === "en_route" || status === "on_scene") {
873
+ for (const rId of inc.assignedResources) {
874
+ const r = state.resources.find((r) => r.id === rId);
875
+ if (r) r.status = status;
876
+ }
877
+ }
878
+
879
+ recalculateAlertLevel(state);
880
+ await saveState(ctx);
881
+
882
+ return {
883
+ incidentId,
884
+ newStatus: status,
885
+ timeline: inc.timeline.slice(-5).map((t) => t.event),
886
+ casualties: inc.casualties,
887
+ systemAlertLevel: state.alertLevel,
888
+ };
889
+ },
890
+ },
891
+
892
+ incident_escalate: {
893
+ description:
894
+ "Escalate an incident when it exceeds current capacity or severity increases.",
895
+ parameters: z.object({
896
+ incidentId: z.string().describe("The incident ID"),
897
+ reason: z.string().describe("Reason for escalation"),
898
+ requestMutualAid: z.boolean().describe(
899
+ "Whether to request mutual aid from neighboring jurisdictions",
900
+ ).optional(),
901
+ newSeverity: z.enum(["critical", "urgent"]).describe(
902
+ "Escalated severity level",
903
+ ).optional(),
904
+ }),
905
+ execute: async (
906
+ { incidentId, reason, requestMutualAid, newSeverity },
907
+ ctx,
908
+ ) => {
909
+ const state = ctx.state;
910
+ const inc = state.incidents[incidentId];
911
+ if (!inc) return { error: `Incident ${incidentId} not found` };
912
+
913
+ inc.escalationLevel++;
914
+ if (newSeverity) inc.severity = newSeverity;
915
+ inc.status = "escalated";
916
+ inc.updatedAt = now();
917
+ inc.timeline.push({
918
+ time: now(),
919
+ event: `ESCALATED (Level ${inc.escalationLevel}): ${reason}`,
920
+ });
921
+ inc.notes.push(`Escalation: ${reason}`);
922
+
923
+ if (requestMutualAid) {
924
+ state.mutualAidRequested = true;
925
+ inc.timeline.push({
926
+ time: now(),
927
+ event: "Mutual aid requested from neighboring jurisdictions",
928
+ });
929
+ // Simulate mutual aid resources arriving
930
+ state.resources.push(
931
+ {
932
+ id: `MA-${Date.now()}-1`,
933
+ type: "ambulance",
934
+ callsign: "Mutual-Aid-Medic",
935
+ status: "available",
936
+ assignedIncident: null,
937
+ eta: null,
938
+ capabilities: ["als"],
939
+ },
940
+ {
941
+ id: `MA-${Date.now()}-2`,
942
+ type: "fire_engine",
943
+ callsign: "Mutual-Aid-Engine",
944
+ status: "available",
945
+ assignedIncident: null,
946
+ eta: null,
947
+ capabilities: ["structural"],
948
+ },
949
+ );
950
+ }
951
+
952
+ inc.triageScore = calculateTriageScore(
953
+ inc.severity,
954
+ inc.type,
955
+ inc.casualties.estimated,
956
+ inc.hazards.length,
957
+ );
958
+ recalculateAlertLevel(state);
959
+ await saveState(ctx);
960
+
961
+ const additionalResources = recommendResources(
962
+ inc.type,
963
+ inc.severity,
964
+ state,
965
+ ).filter(
966
+ (r) => !inc.assignedResources.includes(r.id),
967
+ );
968
+
969
+ return {
970
+ incidentId,
971
+ escalationLevel: inc.escalationLevel,
972
+ newSeverity: inc.severity,
973
+ newTriageScore: inc.triageScore,
974
+ mutualAidRequested: requestMutualAid || false,
975
+ additionalResourcesAvailable: additionalResources.map((r) => ({
976
+ callsign: r.callsign,
977
+ type: r.type,
978
+ })),
979
+ systemAlertLevel: state.alertLevel,
980
+ message:
981
+ `ESCALATION CONFIRMED — ${incidentId} now Level ${inc.escalationLevel}. ${additionalResources.length} additional resource(s) available for dispatch.`,
982
+ };
983
+ },
984
+ },
985
+
986
+ incident_get: {
987
+ description:
988
+ "Get full details on a specific incident including timeline and assigned resources.",
989
+ parameters: z.object({
990
+ incidentId: z.string().describe("The incident ID"),
991
+ }),
992
+ execute: ({ incidentId }, ctx) => {
993
+ const state = ctx.state;
994
+ const inc = state.incidents[incidentId];
995
+ if (!inc) return { error: `Incident ${incidentId} not found` };
996
+
997
+ const assignedResourceDetails = inc.assignedResources.map(
998
+ (rId) => {
999
+ const r = state.resources.find((r) => r.id === rId);
1000
+ return r
1001
+ ? {
1002
+ callsign: r.callsign,
1003
+ type: r.type,
1004
+ status: r.status,
1005
+ eta: r.eta,
1006
+ }
1007
+ : null;
1008
+ },
1009
+ ).filter(Boolean);
1010
+
1011
+ const ageMinutes = Math.round((now() - inc.createdAt) / 60000);
1012
+
1013
+ return {
1014
+ ...inc,
1015
+ ageMinutes,
1016
+ assignedResourceDetails,
1017
+ applicableProtocols: getApplicableProtocols(
1018
+ inc.type,
1019
+ inc.severity,
1020
+ )
1021
+ .map((p) => p.name),
1022
+ };
1023
+ },
1024
+ },
1025
+
1026
+ incident_add_note: {
1027
+ description: "Add a situational update note to an incident.",
1028
+ parameters: z.object({
1029
+ incidentId: z.string().describe("The incident ID"),
1030
+ note: z.string().describe("The note to add"),
1031
+ source: z.string().describe(
1032
+ "Who reported this — unit callsign or caller",
1033
+ ).optional(),
1034
+ }),
1035
+ execute: async ({ incidentId, note, source }, ctx) => {
1036
+ const state = ctx.state;
1037
+ const inc = state.incidents[incidentId];
1038
+ if (!inc) return { error: `Incident ${incidentId} not found` };
1039
+
1040
+ const entry = source ? `[${source}] ${note}` : note;
1041
+ inc.notes.push(entry);
1042
+ inc.timeline.push({ time: now(), event: entry });
1043
+ inc.updatedAt = now();
1044
+ await saveState(ctx);
1045
+
1046
+ return {
1047
+ incidentId,
1048
+ noteAdded: entry,
1049
+ totalNotes: inc.notes.length,
1050
+ };
1051
+ },
1052
+ },
1053
+
1054
+ resources_dispatch: {
1055
+ description:
1056
+ "Dispatch units to an incident. Can auto-dispatch recommended resources or manually specify callsigns.",
1057
+ parameters: z.object({
1058
+ incidentId: z.string().describe("The incident ID"),
1059
+ callsigns: z.array(z.string()).describe(
1060
+ "Resource callsigns to dispatch. Use 'auto' for system-recommended resources.",
1061
+ ).optional(),
1062
+ autoDispatch: z.boolean().describe(
1063
+ "If true, automatically dispatch recommended resources",
1064
+ ).optional(),
1065
+ priority: z.enum(["routine", "priority", "emergency"]).describe(
1066
+ "Dispatch priority — affects simulated ETA",
1067
+ ).optional(),
1068
+ }),
1069
+ execute: async (
1070
+ { incidentId, callsigns, autoDispatch, priority },
1071
+ ctx,
1072
+ ) => {
1073
+ const state = ctx.state;
1074
+ const inc = state.incidents[incidentId];
1075
+ if (!inc) return { error: `Incident ${incidentId} not found` };
1076
+
1077
+ const dispatched: {
1078
+ callsign: string;
1079
+ type: string;
1080
+ eta: number;
1081
+ }[] = [];
1082
+ const failed: { callsign: string; reason: string }[] = [];
1083
+
1084
+ let resourcesToDispatch: Resource[] = [];
1085
+
1086
+ if (autoDispatch) {
1087
+ resourcesToDispatch = recommendResources(
1088
+ inc.type,
1089
+ inc.severity,
1090
+ state,
1091
+ );
1092
+ } else if (callsigns) {
1093
+ for (const cs of callsigns) {
1094
+ const r = state.resources.find((r) =>
1095
+ r.callsign.toLowerCase() === cs.toLowerCase()
1096
+ );
1097
+ if (!r) {
1098
+ failed.push({ callsign: cs, reason: "Not found" });
1099
+ continue;
1100
+ }
1101
+ if (r.status !== "available") {
1102
+ failed.push({
1103
+ callsign: cs,
1104
+ reason: `Currently ${r.status}`,
1105
+ });
1106
+ continue;
1107
+ }
1108
+ resourcesToDispatch.push(r);
1109
+ }
1110
+ }
1111
+
1112
+ const etaBase = priority === "emergency"
1113
+ ? 3
1114
+ : priority === "priority"
1115
+ ? 6
1116
+ : 10;
1117
+
1118
+ for (const r of resourcesToDispatch) {
1119
+ const eta = etaBase + Math.floor(Math.random() * 5);
1120
+ r.status = "dispatched";
1121
+ r.assignedIncident = incidentId;
1122
+ r.eta = eta;
1123
+ inc.assignedResources.push(r.id);
1124
+ dispatched.push({ callsign: r.callsign, type: r.type, eta });
1125
+ inc.timeline.push({
1126
+ time: now(),
1127
+ event: `Dispatched ${r.callsign} — ETA ${eta} min`,
1128
+ });
1129
+ }
1130
+
1131
+ if (dispatched.length > 0) {
1132
+ inc.status = "dispatched";
1133
+ inc.updatedAt = now();
1134
+ }
1135
+
1136
+ recalculateAlertLevel(state);
1137
+ await saveState(ctx);
1138
+
1139
+ const availableCount = state.resources.filter((r) =>
1140
+ r.status === "available"
1141
+ ).length;
1142
+
1143
+ return {
1144
+ incidentId,
1145
+ dispatched,
1146
+ failed: failed.length > 0 ? failed : undefined,
1147
+ totalAssignedToIncident: inc.assignedResources.length,
1148
+ remainingAvailableResources: availableCount,
1149
+ systemAlertLevel: state.alertLevel,
1150
+ capacityWarning: availableCount <= 3
1151
+ ? "WARNING: Resource capacity critically low. Consider mutual aid."
1152
+ : undefined,
1153
+ };
1154
+ },
1155
+ },
1156
+
1157
+ resources_get_available: {
1158
+ description: "List available resources, optionally filtered by type.",
1159
+ parameters: z.object({
1160
+ type: z.enum([
1161
+ "ambulance",
1162
+ "fire_engine",
1163
+ "police",
1164
+ "hazmat_team",
1165
+ "helicopter",
1166
+ "k9_unit",
1167
+ "swat",
1168
+ "ems_supervisor",
1169
+ "all",
1170
+ ]).describe("Filter by resource type, or 'all'").optional(),
1171
+ }),
1172
+ execute: ({ type }, ctx) => {
1173
+ const state = ctx.state;
1174
+ let resources = state.resources;
1175
+ if (type && type !== "all") {
1176
+ resources = resources.filter((r) => r.type === type);
1177
+ }
1178
+
1179
+ return {
1180
+ resources: resources.map((r) => ({
1181
+ callsign: r.callsign,
1182
+ type: r.type,
1183
+ status: r.status,
1184
+ assignedIncident: r.assignedIncident,
1185
+ eta: r.eta,
1186
+ capabilities: r.capabilities,
1187
+ })),
1188
+ summary: {
1189
+ total: resources.length,
1190
+ available: resources.filter((r) => r.status === "available")
1191
+ .length,
1192
+ committed: resources.filter((r) => r.status !== "available")
1193
+ .length,
1194
+ },
1195
+ };
1196
+ },
1197
+ },
1198
+
1199
+ resources_update_status: {
1200
+ description: "Update a resource unit's status when it radios in.",
1201
+ parameters: z.object({
1202
+ callsign: z.string().describe("The resource callsign"),
1203
+ status: z.enum([
1204
+ "available",
1205
+ "dispatched",
1206
+ "en_route",
1207
+ "on_scene",
1208
+ "returning",
1209
+ ]).describe("New status"),
1210
+ notes: z.string().describe("Status notes").optional(),
1211
+ }),
1212
+ execute: async ({ callsign, status, notes }, ctx) => {
1213
+ const state = ctx.state;
1214
+ const resource = state.resources.find((r) =>
1215
+ r.callsign.toLowerCase() === callsign.toLowerCase()
1216
+ );
1217
+ if (!resource) {
1218
+ return { error: `Resource ${callsign} not found` };
1219
+ }
1220
+
1221
+ const previousStatus = resource.status;
1222
+ resource.status = status;
1223
+
1224
+ if (status === "available") {
1225
+ resource.assignedIncident = null;
1226
+ resource.eta = null;
1227
+ }
1228
+
1229
+ // Log to incident timeline if assigned
1230
+ if (resource.assignedIncident) {
1231
+ const inc = state.incidents[resource.assignedIncident];
1232
+ if (inc) {
1233
+ inc.timeline.push({
1234
+ time: now(),
1235
+ event: `${callsign}: ${previousStatus} → ${status}${
1236
+ notes ? ` (${notes})` : ""
1237
+ }`,
1238
+ });
1239
+ inc.updatedAt = now();
1240
+ }
1241
+ }
1242
+
1243
+ recalculateAlertLevel(state);
1244
+ await saveState(ctx);
1245
+
1246
+ return {
1247
+ callsign: resource.callsign,
1248
+ previousStatus,
1249
+ newStatus: status,
1250
+ assignedIncident: resource.assignedIncident,
1251
+ systemAlertLevel: state.alertLevel,
1252
+ };
1253
+ },
1254
+ },
1255
+
1256
+ ops_dashboard: {
1257
+ description:
1258
+ "Get the full operational dashboard: alert level, resource utilization, active incidents, and available resources.",
1259
+ execute: (_args, ctx) => {
1260
+ const state = ctx.state;
1261
+
1262
+ const activeIncidents = Object.values(state.incidents)
1263
+ .filter((i) => i.status !== "resolved")
1264
+ .sort((a, b) => b.triageScore - a.triageScore);
1265
+
1266
+ const resolvedCount =
1267
+ Object.values(state.incidents).filter((i) => i.status === "resolved")
1268
+ .length;
1269
+
1270
+ const resourceSummary = {
1271
+ total: state.resources.length,
1272
+ available:
1273
+ state.resources.filter((r) => r.status === "available").length,
1274
+ dispatched:
1275
+ state.resources.filter((r) => r.status === "dispatched").length,
1276
+ enRoute:
1277
+ state.resources.filter((r) => r.status === "en_route").length,
1278
+ onScene:
1279
+ state.resources.filter((r) => r.status === "on_scene").length,
1280
+ returning:
1281
+ state.resources.filter((r) => r.status === "returning").length,
1282
+ };
1283
+
1284
+ const utilization = Math.round(
1285
+ (1 - resourceSummary.available / resourceSummary.total) * 100,
1286
+ );
1287
+
1288
+ return {
1289
+ systemAlertLevel: state.alertLevel,
1290
+ mutualAidActive: state.mutualAidRequested,
1291
+ resourceUtilization: `${utilization}%`,
1292
+ resourceSummary,
1293
+ activeIncidentCount: activeIncidents.length,
1294
+ resolvedIncidentCount: resolvedCount,
1295
+ activeIncidents: activeIncidents.map((i) => ({
1296
+ id: i.id,
1297
+ type: i.type,
1298
+ severity: i.severity,
1299
+ status: i.status,
1300
+ location: i.location,
1301
+ triageScore: i.triageScore,
1302
+ assignedResourceCount: i.assignedResources.length,
1303
+ ageMinutes: Math.round((now() - i.createdAt) / 60000),
1304
+ casualties: i.casualties,
1305
+ })),
1306
+ availableResources: state.resources.filter((r) =>
1307
+ r.status === "available"
1308
+ ).map((r) => ({
1309
+ callsign: r.callsign,
1310
+ type: r.type,
1311
+ capabilities: r.capabilities,
1312
+ })),
1313
+ };
1314
+ },
1315
+ },
1316
+
1317
+ ops_protocols: {
1318
+ description:
1319
+ "Look up step-by-step response protocols for a given incident type and severity.",
1320
+ parameters: z.object({
1321
+ incidentType: z.enum([
1322
+ "medical",
1323
+ "fire",
1324
+ "hazmat",
1325
+ "traffic",
1326
+ "crime",
1327
+ "natural_disaster",
1328
+ "utility",
1329
+ "other",
1330
+ ]).describe("Type of incident"),
1331
+ severity: z.enum(["critical", "urgent", "moderate", "minor"])
1332
+ .describe("Severity level"),
1333
+ }),
1334
+ execute: ({ incidentType, severity }) => {
1335
+ const protocols = getApplicableProtocols(
1336
+ incidentType,
1337
+ severity,
1338
+ );
1339
+ if (protocols.length === 0) {
1340
+ return {
1341
+ message:
1342
+ "No specific protocols for this combination. Use standard operating procedures.",
1343
+ protocols: [],
1344
+ };
1345
+ }
1346
+ return {
1347
+ protocols: protocols.map((p) => ({
1348
+ name: p.name,
1349
+ steps: p.steps,
1350
+ requiredResources: p.requiredResources,
1351
+ })),
1352
+ };
1353
+ },
1354
+ },
1355
+
1356
+ ops_run_scenario: {
1357
+ description:
1358
+ "Run a training scenario that creates simulated incidents for dispatch practice.",
1359
+ parameters: z.object({
1360
+ scenario: z.enum([
1361
+ "mass_casualty",
1362
+ "multi_alarm_fire",
1363
+ "active_shooter",
1364
+ "natural_disaster",
1365
+ "highway_pileup",
1366
+ ]).describe("Scenario type to simulate"),
1367
+ }),
1368
+ execute: async ({ scenario }, ctx) => {
1369
+ const state = ctx.state;
1370
+ const scenarios: Record<
1371
+ string,
1372
+ { incidents: Partial<Incident>[]; narrative: string }
1373
+ > = {
1374
+ "mass_casualty": {
1375
+ narrative:
1376
+ "Bus crash at Main and 5th. School bus vs delivery truck. Multiple pediatric patients. Fuel spill on roadway.",
1377
+ incidents: [
1378
+ {
1379
+ location: "Main St and 5th Ave intersection",
1380
+ description:
1381
+ "School bus collision with delivery truck, multiple children injured, bus on its side, fuel leaking",
1382
+ type: "traffic",
1383
+ severity: "critical",
1384
+ },
1385
+ {
1386
+ location: "Main St and 5th Ave — fuel spill",
1387
+ description:
1388
+ "Diesel fuel spill from delivery truck spreading toward storm drain, approximately 50 gallons",
1389
+ type: "hazmat",
1390
+ severity: "urgent",
1391
+ },
1392
+ ],
1393
+ },
1394
+ "multi_alarm_fire": {
1395
+ narrative:
1396
+ "Working structure fire at 200 Industrial Parkway. 3-story warehouse, heavy smoke showing. Reports of workers possibly trapped on upper floors.",
1397
+ incidents: [
1398
+ {
1399
+ location: "200 Industrial Parkway",
1400
+ description:
1401
+ "3-story warehouse fully involved, heavy fire showing from all floors, possible trapped occupants on 2nd and 3rd floor, exposure buildings within 50 feet",
1402
+ type: "fire",
1403
+ severity: "critical",
1404
+ },
1405
+ {
1406
+ location: "200 Industrial Parkway — medical",
1407
+ description:
1408
+ "2 workers with smoke inhalation evacuated from ground floor, one with burns to hands and arms",
1409
+ type: "medical",
1410
+ severity: "urgent",
1411
+ },
1412
+ ],
1413
+ },
1414
+ "active_shooter": {
1415
+ narrative:
1416
+ "Reports of active shooter at Riverside Mall. Multiple shots fired, crowds fleeing. At least 3 victims reported down in food court area.",
1417
+ incidents: [
1418
+ {
1419
+ location: "Riverside Mall, 1500 River Road — food court",
1420
+ description:
1421
+ "Active shooter in food court area, multiple shots fired, at least 3 victims down, shooter last seen moving toward west entrance",
1422
+ type: "crime",
1423
+ severity: "critical",
1424
+ },
1425
+ {
1426
+ location: "Riverside Mall parking lot",
1427
+ description:
1428
+ "Crowd crush injuries as people fled the building, several people trampled near east exit",
1429
+ type: "medical",
1430
+ severity: "urgent",
1431
+ },
1432
+ ],
1433
+ },
1434
+ "natural_disaster": {
1435
+ narrative:
1436
+ "EF-3 tornado touched down in residential area. Path of destruction along Oak Street corridor. Multiple structures collapsed. Power lines down.",
1437
+ incidents: [
1438
+ {
1439
+ location: "Oak Street between 10th and 15th",
1440
+ description:
1441
+ "Tornado damage, multiple homes collapsed, people trapped in rubble, gas lines ruptured",
1442
+ type: "natural_disaster",
1443
+ severity: "critical",
1444
+ },
1445
+ {
1446
+ location: "Oak Street Elementary School",
1447
+ description:
1448
+ "School roof partially collapsed, staff sheltering students in interior rooms, requesting welfare check",
1449
+ type: "natural_disaster",
1450
+ severity: "critical",
1451
+ },
1452
+ {
1453
+ location: "Oak Street and 12th — utility",
1454
+ description:
1455
+ "Multiple downed power lines sparking, gas main rupture, area needs immediate isolation",
1456
+ type: "utility",
1457
+ severity: "urgent",
1458
+ },
1459
+ ],
1460
+ },
1461
+ "highway_pileup": {
1462
+ narrative:
1463
+ "20-plus vehicle pileup on Interstate 95 southbound near mile marker 42. Fog conditions. Multiple entrapments. Tanker truck involved.",
1464
+ incidents: [
1465
+ {
1466
+ location: "I-95 southbound mile marker 42",
1467
+ description:
1468
+ "Multi-vehicle pileup, 20-plus vehicles, multiple entrapments, tanker truck involved with unknown cargo, heavy fog limiting visibility",
1469
+ type: "traffic",
1470
+ severity: "critical",
1471
+ },
1472
+ {
1473
+ location: "I-95 southbound — hazmat",
1474
+ description:
1475
+ "Tanker truck leaking unknown liquid, placards not yet visible due to fog, setting up exclusion zone",
1476
+ type: "hazmat",
1477
+ severity: "critical",
1478
+ },
1479
+ ],
1480
+ },
1481
+ };
1482
+
1483
+ const s = scenarios[scenario];
1484
+ if (!s) return { error: "Unknown scenario" };
1485
+
1486
+ const created: string[] = [];
1487
+ for (const inc of s.incidents) {
1488
+ state.incidentCounter++;
1489
+ const id = `INC-${String(state.incidentCounter).padStart(4, "0")}`;
1490
+ const fullInc: Incident = {
1491
+ id,
1492
+ type: inc.type || "other",
1493
+ severity: inc.severity || "moderate",
1494
+ status: "incoming",
1495
+ location: inc.location || "Unknown",
1496
+ description: inc.description || "",
1497
+ callerName: "Scenario",
1498
+ callerPhone: "N/A",
1499
+ triageScore: calculateTriageScore(
1500
+ (inc.severity || "moderate") as Severity,
1501
+ (inc.type || "other") as IncidentType,
1502
+ 0,
1503
+ 0,
1504
+ ),
1505
+ assignedResources: [],
1506
+ timeline: [{
1507
+ time: now(),
1508
+ event: `SCENARIO: ${inc.description}`,
1509
+ }],
1510
+ notes: [],
1511
+ createdAt: now(),
1512
+ updatedAt: now(),
1513
+ escalationLevel: 0,
1514
+ protocolsActivated: [],
1515
+ casualties: { confirmed: 0, estimated: 0, treated: 0 },
1516
+ hazards: [],
1517
+ };
1518
+ state.incidents[id] = fullInc;
1519
+ created.push(id);
1520
+ }
1521
+
1522
+ recalculateAlertLevel(state);
1523
+ await saveState(ctx);
1524
+
1525
+ return {
1526
+ scenario,
1527
+ narrative: s.narrative,
1528
+ incidentsCreated: created,
1529
+ systemAlertLevel: state.alertLevel,
1530
+ message:
1531
+ `SCENARIO ACTIVE: ${s.narrative}. ${created.length} incidents created. Awaiting dispatch orders.`,
1532
+ };
1533
+ },
1534
+ },
1535
+ },
1536
+ });