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