rhombus-node-mcp 0.1.53 → 0.1.55
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.
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { buildMediaHints } from "./badge-correlation.js";
|
|
2
|
+
import { searchElementsEvents } from "./elements-tool-api.js";
|
|
3
|
+
import { getCameraList } from "./get-entity-tool-api.js";
|
|
4
|
+
import { searchNetboxEvents } from "./netbox-tool-api.js";
|
|
5
|
+
import { searchOnGuardEvents } from "./onguard-tool-api.js";
|
|
6
|
+
import { listReidentificationEmbeddings, searchReidentificationMatchesByEmbedding } from "./reid-tool-api.js";
|
|
7
|
+
import { formatTimestamp } from "../util.js";
|
|
8
|
+
const DEFAULT_BADGE_MATCH_WINDOW_S = 30;
|
|
9
|
+
const DEFAULT_TRACK_FORWARD_MS = 6 * 60 * 60 * 1000; // track 6h forward from the badge tap if no endTime
|
|
10
|
+
/** RUUID without any `.vN` facet suffix, so a badge event's camera matches the camera-state list. */
|
|
11
|
+
function stripFacet(uuid) {
|
|
12
|
+
return (uuid ?? "").split(".")[0];
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Reconstructs where a named person went, grounded in access control + person re-identification:
|
|
16
|
+
* 1. find the person's badge tap(s) (OnGuard / Elements / NetBox) → a camera + time we KNOW is them,
|
|
17
|
+
* 2. pull the re-id embedding recorded on that camera nearest the badge time (the person at the door),
|
|
18
|
+
* 3. re-id-search that embedding across cameras over the window → their cross-camera movement track.
|
|
19
|
+
*
|
|
20
|
+
* Identity comes from the badge (not face recognition); the track is appearance/re-id based.
|
|
21
|
+
*/
|
|
22
|
+
export async function getPersonTrack(args, timeZone, requestModifiers, sessionId) {
|
|
23
|
+
// 1. Anchor on access-control events. Check all three badge integrations; an org may use any.
|
|
24
|
+
const badgeArgs = {
|
|
25
|
+
cardholderQuery: args.personQuery,
|
|
26
|
+
locationUuids: args.locationUuids,
|
|
27
|
+
afterMs: args.afterMs,
|
|
28
|
+
beforeMs: args.beforeMs,
|
|
29
|
+
limit: args.limit ?? 200,
|
|
30
|
+
};
|
|
31
|
+
const empty = { events: [] };
|
|
32
|
+
const [og, el, nb] = await Promise.all([
|
|
33
|
+
searchOnGuardEvents(badgeArgs, timeZone, requestModifiers, sessionId).catch(() => empty),
|
|
34
|
+
searchElementsEvents(badgeArgs, timeZone, requestModifiers, sessionId).catch(() => empty),
|
|
35
|
+
searchNetboxEvents(badgeArgs, timeZone, requestModifiers, sessionId).catch(() => empty),
|
|
36
|
+
]);
|
|
37
|
+
const badgeEvents = [
|
|
38
|
+
...og.events.map((e) => ({ ...e, integration: "OnGuard" })),
|
|
39
|
+
...el.events.map((e) => ({ ...e, integration: "Elements" })),
|
|
40
|
+
...nb.events.map((e) => ({ ...e, integration: "NetBox" })),
|
|
41
|
+
]
|
|
42
|
+
.filter((e) => e.deviceUuid && e.timestampMs != null)
|
|
43
|
+
.sort((a, b) => (a.timestampMs ?? 0) - (b.timestampMs ?? 0));
|
|
44
|
+
if (badgeEvents.length === 0) {
|
|
45
|
+
return {
|
|
46
|
+
sightings: [],
|
|
47
|
+
path: [],
|
|
48
|
+
count: 0,
|
|
49
|
+
note: `No access-control (badge) events found for "${args.personQuery}" in the window — can't anchor a re-id track. Try a wider time range, or confirm the name as it appears on the badge.`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
// Earliest badge tap in the window — track forward from there.
|
|
53
|
+
const anchor = badgeEvents[0];
|
|
54
|
+
const resolvedPerson = anchor.cardholderName ? { name: anchor.cardholderName } : undefined;
|
|
55
|
+
const anchorOut = {
|
|
56
|
+
deviceUuid: anchor.deviceUuid,
|
|
57
|
+
timestampMs: anchor.timestampMs,
|
|
58
|
+
datetime: anchor.datetime,
|
|
59
|
+
integration: anchor.integration,
|
|
60
|
+
area: anchor.areaEntering ?? anchor.areaExiting,
|
|
61
|
+
};
|
|
62
|
+
// 2. Resolve the badge camera's location (re-id list is scoped by location).
|
|
63
|
+
let anchorLocationUuid;
|
|
64
|
+
try {
|
|
65
|
+
const { cameras } = await getCameraList(requestModifiers, sessionId);
|
|
66
|
+
const dev = stripFacet(anchor.deviceUuid);
|
|
67
|
+
anchorLocationUuid = cameras.find((c) => stripFacet(c.uuid) === dev)?.locationUuid;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// best-effort; the re-id list can still run device-scoped without a location
|
|
71
|
+
}
|
|
72
|
+
// 3. Ground the re-id embedding: the person detected on that camera nearest the badge tap.
|
|
73
|
+
const winMs = (args.badgeMatchWindowSeconds ?? DEFAULT_BADGE_MATCH_WINDOW_S) * 1000;
|
|
74
|
+
const anchorMs = anchor.timestampMs;
|
|
75
|
+
const embeddings = await listReidentificationEmbeddings({
|
|
76
|
+
deviceUuids: [anchor.deviceUuid],
|
|
77
|
+
locationUuid: anchorLocationUuid,
|
|
78
|
+
startTimestampMs: anchorMs - winMs,
|
|
79
|
+
endTimestampMs: anchorMs + winMs,
|
|
80
|
+
limit: 100,
|
|
81
|
+
}, requestModifiers, sessionId);
|
|
82
|
+
if (embeddings.length === 0) {
|
|
83
|
+
return {
|
|
84
|
+
resolvedPerson,
|
|
85
|
+
anchor: anchorOut,
|
|
86
|
+
sightings: [],
|
|
87
|
+
path: [],
|
|
88
|
+
count: 0,
|
|
89
|
+
note: `Found ${anchor.cardholderName ?? "the person"}'s badge tap at ${anchor.datetime}, but no person re-identification embedding was recorded on that camera within ±${args.badgeMatchWindowSeconds ?? DEFAULT_BADGE_MATCH_WINDOW_S}s — can't build a re-id track. (Re-id needs human-detection coverage on the door camera.)`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
// The detection closest in time to the badge tap is the best proxy for the badge holder.
|
|
93
|
+
const seed = embeddings.reduce((best, e) => Math.abs((e.timestamp ?? 0) - anchorMs) < Math.abs((best.timestamp ?? 0) - anchorMs) ? e : best);
|
|
94
|
+
if (!seed.embedding || seed.embedding.length === 0) {
|
|
95
|
+
return {
|
|
96
|
+
resolvedPerson,
|
|
97
|
+
anchor: anchorOut,
|
|
98
|
+
sightings: [],
|
|
99
|
+
path: [],
|
|
100
|
+
count: 0,
|
|
101
|
+
note: "The re-id detection at the door had no embedding vector; can't search for matches.",
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
// 4. Re-id search that appearance across cameras over the window.
|
|
105
|
+
const searchStart = args.afterMs ?? anchorMs;
|
|
106
|
+
const searchEnd = args.beforeMs ?? anchorMs + DEFAULT_TRACK_FORWARD_MS;
|
|
107
|
+
const matches = await searchReidentificationMatchesByEmbedding({
|
|
108
|
+
searchEmbedding: seed.embedding,
|
|
109
|
+
locationUuid: args.locationUuids?.[0],
|
|
110
|
+
startTimestampMs: searchStart,
|
|
111
|
+
endTimestampMs: searchEnd,
|
|
112
|
+
limit: args.limit ?? 200,
|
|
113
|
+
}, requestModifiers, sessionId);
|
|
114
|
+
// 5. Build the chronological track with media hints.
|
|
115
|
+
const ordered = matches
|
|
116
|
+
.filter((m) => m.timestamp != null)
|
|
117
|
+
.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0));
|
|
118
|
+
const sightings = ordered.map((m, i) => {
|
|
119
|
+
const next = ordered[i + 1];
|
|
120
|
+
const gapToNextSeconds = next?.timestamp != null && m.timestamp != null
|
|
121
|
+
? Math.round((next.timestamp - m.timestamp) / 1000)
|
|
122
|
+
: undefined;
|
|
123
|
+
const { clipHint, stillHint } = buildMediaHints({ deviceUuid: m.deviceUuid, timestampMs: m.timestamp }, args.clipPaddingSeconds);
|
|
124
|
+
return {
|
|
125
|
+
timestampMs: m.timestamp,
|
|
126
|
+
datetime: m.timestamp != null ? formatTimestamp(m.timestamp, timeZone) : undefined,
|
|
127
|
+
deviceUuid: m.deviceUuid,
|
|
128
|
+
locationUuid: m.locationUuid,
|
|
129
|
+
distance: m.distance,
|
|
130
|
+
stableTrackId: m.stableTrackId,
|
|
131
|
+
thumbnailUri: m.thumbnailUri,
|
|
132
|
+
clipHint,
|
|
133
|
+
stillHint,
|
|
134
|
+
gapToNextSeconds,
|
|
135
|
+
};
|
|
136
|
+
});
|
|
137
|
+
const path = [];
|
|
138
|
+
for (const s of sightings) {
|
|
139
|
+
if (s.deviceUuid && s.deviceUuid !== path[path.length - 1])
|
|
140
|
+
path.push(s.deviceUuid);
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
resolvedPerson,
|
|
144
|
+
anchor: anchorOut,
|
|
145
|
+
sightings,
|
|
146
|
+
path,
|
|
147
|
+
lastKnownSighting: sightings[sightings.length - 1],
|
|
148
|
+
count: sightings.length,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { postApi } from "../network/network.js";
|
|
2
|
+
function mapEmbedding(e) {
|
|
3
|
+
return {
|
|
4
|
+
deviceUuid: e.deviceUuid ?? undefined,
|
|
5
|
+
locationUuid: e.locationUuid ?? undefined,
|
|
6
|
+
timestamp: e.timestamp ?? undefined,
|
|
7
|
+
embedding: (e.embedding ?? []).filter((n) => n != null),
|
|
8
|
+
embeddingId: e.embeddingId ?? undefined,
|
|
9
|
+
stableTrackId: e.stableTrackId ?? undefined,
|
|
10
|
+
thumbnailUri: e.thumbnailUri ?? undefined,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Lists the person re-identification embeddings recorded on the given camera(s) in a time window —
|
|
15
|
+
* i.e. the people the camera's human-detection AI saw. Used to grab the embedding of the person standing
|
|
16
|
+
* at a door at a known moment (e.g. a badge tap), so it can be tracked across other cameras.
|
|
17
|
+
*/
|
|
18
|
+
export async function listReidentificationEmbeddings(args, requestModifiers, sessionId) {
|
|
19
|
+
const body = {
|
|
20
|
+
deviceUuids: args.deviceUuids,
|
|
21
|
+
locationUuid: args.locationUuid,
|
|
22
|
+
startTimestampMs: args.startTimestampMs,
|
|
23
|
+
endTimestampMs: args.endTimestampMs,
|
|
24
|
+
limit: args.limit ?? 100,
|
|
25
|
+
};
|
|
26
|
+
const res = await postApi({
|
|
27
|
+
route: "/search/listReidentificationEmbeddings",
|
|
28
|
+
body,
|
|
29
|
+
modifiers: requestModifiers,
|
|
30
|
+
sessionId,
|
|
31
|
+
});
|
|
32
|
+
if (res.error)
|
|
33
|
+
throw new Error(res.errorMsg ?? "listReidentificationEmbeddings failed");
|
|
34
|
+
return (res.embeddings ?? []).map(mapEmbedding);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Searches person re-identification matches for a given appearance embedding across cameras and time —
|
|
38
|
+
* "find this same person elsewhere". Returns sightings ordered by similarity (distance asc; lower is a
|
|
39
|
+
* closer match).
|
|
40
|
+
*/
|
|
41
|
+
export async function searchReidentificationMatchesByEmbedding(args, requestModifiers, sessionId) {
|
|
42
|
+
const body = {
|
|
43
|
+
searchEmbedding: args.searchEmbedding,
|
|
44
|
+
deviceUuids: args.deviceUuids,
|
|
45
|
+
locationUuid: args.locationUuid,
|
|
46
|
+
startTimestampMs: args.startTimestampMs,
|
|
47
|
+
endTimestampMs: args.endTimestampMs,
|
|
48
|
+
limit: args.limit ?? 100,
|
|
49
|
+
};
|
|
50
|
+
const res = await postApi({
|
|
51
|
+
route: "/search/searchReidentificationMatchesByEmbedding",
|
|
52
|
+
body,
|
|
53
|
+
modifiers: requestModifiers,
|
|
54
|
+
sessionId,
|
|
55
|
+
});
|
|
56
|
+
if (res.error)
|
|
57
|
+
throw new Error(res.errorMsg ?? "searchReidentificationMatchesByEmbedding failed");
|
|
58
|
+
return (res.matches ?? []).map((m) => ({
|
|
59
|
+
...mapEmbedding(m.embedding ?? {}),
|
|
60
|
+
distance: m.distance ?? undefined,
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { getPersonTrack } from "../api/person-tracking-tool-api.js";
|
|
2
|
+
import { OUTPUT_SCHEMA, TOOL_ARGS } from "../types/person-tracking-tool-types.js";
|
|
3
|
+
import { createToolStructuredContent, extractFromToolExtra } from "../util.js";
|
|
4
|
+
const TOOL_NAME = "person-tracking-tool";
|
|
5
|
+
const TOOL_DESCRIPTION = `
|
|
6
|
+
Reconstructs where a named person went across cameras, e.g. "show me where Brandon Salzberg went" or
|
|
7
|
+
"track Eve through the building yesterday". Identity is grounded in ACCESS CONTROL and the track uses
|
|
8
|
+
person RE-IDENTIFICATION (appearance), NOT face recognition:
|
|
9
|
+
|
|
10
|
+
1. Finds the person's badge tap(s) (OnGuard / Elements / NetBox) in the window — a camera + time we KNOW
|
|
11
|
+
is them. (Pass the name as it appears on the badge.)
|
|
12
|
+
2. Pulls the person re-id embedding recorded on that door camera nearest the badge tap (the person at the
|
|
13
|
+
door).
|
|
14
|
+
3. Re-id-searches that appearance across all cameras over the window to reconstruct their movement.
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
- resolvedPerson and "anchor" (the badge tap that grounded the track: door camera, time, integration).
|
|
18
|
+
- sightings: chronological re-id hits, each with deviceUuid (camera), timestampMs/datetime, distance
|
|
19
|
+
(LOWER = closer appearance match), a thumbnail, clipHint/stillHint, and gapToNextSeconds.
|
|
20
|
+
- path (camera sequence) and lastKnownSighting (last-known location).
|
|
21
|
+
- note: set when no badge tap was found, or no re-id embedding existed on the door camera.
|
|
22
|
+
|
|
23
|
+
Resolve relative times like "yesterday" to ISO 8601 first (use the timestamp tool), then pass
|
|
24
|
+
startTime/endTime. Re-id depends on human-detection coverage, so treat the track as investigative, not proof.
|
|
25
|
+
|
|
26
|
+
IMPORTANT — to show the movement visually: for each sighting (or the key transitions), call the camera-tool
|
|
27
|
+
(requestType "image", cameraUuid = sighting.deviceUuid, timestamp = sighting.timestampMs) for a still, and/or
|
|
28
|
+
the clips-tool (requestType "createClip", using sighting.clipHint) for video. Issue those per-sighting media
|
|
29
|
+
calls in PARALLEL, then present the track as a chronological narrative.
|
|
30
|
+
`;
|
|
31
|
+
const TOOL_HANDLER = async (args, _extra) => {
|
|
32
|
+
const { requestModifiers, sessionId } = extractFromToolExtra(_extra);
|
|
33
|
+
try {
|
|
34
|
+
const result = await getPersonTrack({
|
|
35
|
+
personQuery: args.personQuery,
|
|
36
|
+
afterMs: args.startTime ? new Date(args.startTime).getTime() : undefined,
|
|
37
|
+
beforeMs: args.endTime ? new Date(args.endTime).getTime() : undefined,
|
|
38
|
+
locationUuids: args.locationUuids ?? undefined,
|
|
39
|
+
badgeMatchWindowSeconds: args.badgeMatchWindowSeconds ?? undefined,
|
|
40
|
+
clipPaddingSeconds: args.clipPaddingSeconds ?? undefined,
|
|
41
|
+
limit: args.limit ?? undefined,
|
|
42
|
+
}, args.timeZone ?? "UTC", requestModifiers, sessionId);
|
|
43
|
+
return createToolStructuredContent(result);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
47
|
+
return createToolStructuredContent({ error: message });
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
export function createTool(server) {
|
|
51
|
+
server.registerTool(TOOL_NAME, {
|
|
52
|
+
description: TOOL_DESCRIPTION,
|
|
53
|
+
inputSchema: TOOL_ARGS,
|
|
54
|
+
outputSchema: OUTPUT_SCHEMA.shape,
|
|
55
|
+
}, TOOL_HANDLER);
|
|
56
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { createUuidSchema } from "../types.js";
|
|
3
|
+
import { ISOTimestampFormatDescription } from "../utils/timestampInput.js";
|
|
4
|
+
export const TOOL_ARGS = {
|
|
5
|
+
personQuery: z
|
|
6
|
+
.string()
|
|
7
|
+
.describe('The person to track, full-text name match against their access-control (badge) records, e.g. "Brandon" or "Brandon Salzberg".'),
|
|
8
|
+
startTime: z
|
|
9
|
+
.string()
|
|
10
|
+
.datetime({ message: "Invalid datetime string. Expected ISO 8601 format.", offset: true })
|
|
11
|
+
.nullable()
|
|
12
|
+
.describe("Start of the window to search badge taps and track over (inclusive). " + ISOTimestampFormatDescription),
|
|
13
|
+
endTime: z
|
|
14
|
+
.string()
|
|
15
|
+
.datetime({ message: "Invalid datetime string. Expected ISO 8601 format.", offset: true })
|
|
16
|
+
.nullable()
|
|
17
|
+
.describe("End of the window (inclusive). " + ISOTimestampFormatDescription),
|
|
18
|
+
locationUuids: z
|
|
19
|
+
.array(createUuidSchema())
|
|
20
|
+
.nullable()
|
|
21
|
+
.describe("Optional: restrict badge search and the re-id track to these Rhombus location UUIDs."),
|
|
22
|
+
badgeMatchWindowSeconds: z
|
|
23
|
+
.number()
|
|
24
|
+
.nullable()
|
|
25
|
+
.describe("± seconds around the badge tap to look on the door camera for the person's re-id embedding (default 30)."),
|
|
26
|
+
clipPaddingSeconds: z
|
|
27
|
+
.number()
|
|
28
|
+
.nullable()
|
|
29
|
+
.describe("Seconds of video before/after each sighting to include in the clip hint (default 15)."),
|
|
30
|
+
limit: z.number().nullable().describe("Maximum sightings to include (default 200)."),
|
|
31
|
+
timeZone: z
|
|
32
|
+
.string()
|
|
33
|
+
.nullable()
|
|
34
|
+
.describe("IANA timezone used to format times, e.g. America/New_York. Defaults to UTC."),
|
|
35
|
+
};
|
|
36
|
+
const TOOL_ARGS_SCHEMA = z.object(TOOL_ARGS);
|
|
37
|
+
const ClipHintSchema = z
|
|
38
|
+
.object({ deviceUuid: z.string(), startTimeMs: z.number(), endTimeMs: z.number() })
|
|
39
|
+
.describe("Pass to clips-tool createClip to get video of this sighting.");
|
|
40
|
+
const StillHintSchema = z
|
|
41
|
+
.object({ deviceUuid: z.string(), timestampMs: z.number() })
|
|
42
|
+
.describe("Pass to camera-tool (requestType image) to get a still of this sighting.");
|
|
43
|
+
export const SightingSchema = z.object({
|
|
44
|
+
timestampMs: z.number().optional(),
|
|
45
|
+
datetime: z.string().optional().describe("Human-readable sighting time in the requested timezone."),
|
|
46
|
+
deviceUuid: z.string().optional().describe("The camera that re-identified the person."),
|
|
47
|
+
locationUuid: z.string().optional(),
|
|
48
|
+
distance: z
|
|
49
|
+
.number()
|
|
50
|
+
.optional()
|
|
51
|
+
.describe("Re-id match distance to the door embedding — LOWER means a closer appearance match."),
|
|
52
|
+
stableTrackId: z.number().optional().describe("Per-camera track-consolidation id for this detection."),
|
|
53
|
+
thumbnailUri: z.string().optional().describe("Thumbnail of the detected person."),
|
|
54
|
+
clipHint: ClipHintSchema.optional(),
|
|
55
|
+
stillHint: StillHintSchema.optional(),
|
|
56
|
+
gapToNextSeconds: z
|
|
57
|
+
.number()
|
|
58
|
+
.optional()
|
|
59
|
+
.describe("Seconds until the next sighting — large gaps mean the person was unobserved between cameras."),
|
|
60
|
+
});
|
|
61
|
+
const AnchorSchema = z
|
|
62
|
+
.object({
|
|
63
|
+
deviceUuid: z.string().optional().describe("The door camera where the badge tap happened."),
|
|
64
|
+
timestampMs: z.number().optional(),
|
|
65
|
+
datetime: z.string().optional(),
|
|
66
|
+
integration: z.string().optional().describe("Which badge system the tap came from (OnGuard / Elements / NetBox)."),
|
|
67
|
+
area: z.string().optional(),
|
|
68
|
+
})
|
|
69
|
+
.describe("The access-control badge tap used to ground the re-id track (the known identity moment).");
|
|
70
|
+
export const OUTPUT_SCHEMA = z.object({
|
|
71
|
+
resolvedPerson: z
|
|
72
|
+
.object({ name: z.string().optional() })
|
|
73
|
+
.optional()
|
|
74
|
+
.describe("The person resolved from the badge record."),
|
|
75
|
+
anchor: AnchorSchema.optional(),
|
|
76
|
+
sightings: z
|
|
77
|
+
.array(SightingSchema)
|
|
78
|
+
.optional()
|
|
79
|
+
.describe("Re-id sightings of the person across cameras, in chronological order (oldest first)."),
|
|
80
|
+
path: z
|
|
81
|
+
.array(z.string())
|
|
82
|
+
.optional()
|
|
83
|
+
.describe("Camera UUIDs the person was re-identified at, in order (consecutive repeats collapsed)."),
|
|
84
|
+
lastKnownSighting: SightingSchema.optional().describe("The most recent re-id sighting — last-known location."),
|
|
85
|
+
count: z.number().optional().describe("Number of re-id sightings returned."),
|
|
86
|
+
note: z.string().optional().describe("Set when the track couldn't be built (no badge tap, or no re-id at the door)."),
|
|
87
|
+
error: z.string().optional(),
|
|
88
|
+
});
|