rhombus-node-mcp 0.1.54 → 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.
@@ -1,134 +1,139 @@
1
1
  import { buildMediaHints } from "./badge-correlation.js";
2
- import { getFaceEvents, getRegisteredFaces, searchSimilarFaces } from "./faces-tool-api.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";
3
7
  import { formatTimestamp } from "../util.js";
4
- /**
5
- * Fuzzy-match a free-text name against the registered-faces directory. Mirrors faces-tool's scoring
6
- * (4 = exact full name, 3 = first name, 2 = last name, 1 = substring ≥3 chars) but returns every person
7
- * tied at the best score so the caller can flag ambiguity instead of silently tracking the wrong person.
8
- */
9
- function matchRegisteredPeople(query, people) {
10
- const input = query.toLowerCase().trim();
11
- if (!input)
12
- return [];
13
- let bestScore = 0;
14
- const scored = [];
15
- for (const person of people) {
16
- if (!person.name || !person.uuid)
17
- continue;
18
- const fullName = person.name.toLowerCase().trim();
19
- const parts = fullName.split(/\s+/);
20
- let score = 0;
21
- if (fullName === input)
22
- score = 4;
23
- else if (parts[0] === input)
24
- score = 3;
25
- else if (parts.length > 1 && parts[parts.length - 1] === input)
26
- score = 2;
27
- else if (input.length >= 3 && fullName.includes(input))
28
- score = 1;
29
- if (score > 0) {
30
- scored.push({ name: person.name, uuid: person.uuid, score });
31
- if (score > bestScore)
32
- bestScore = score;
33
- }
34
- }
35
- // Dedupe by uuid among the best-scoring tier.
36
- const seen = new Set();
37
- const top = [];
38
- for (const s of scored) {
39
- if (s.score !== bestScore || seen.has(s.uuid))
40
- continue;
41
- seen.add(s.uuid);
42
- top.push({ name: s.name, personUuid: s.uuid });
43
- }
44
- return top;
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];
45
13
  }
46
14
  /**
47
- * Reconstructs one person's movements across cameras as a chronological track of face sightings, each
48
- * with the still/clip hints needed to show the moment. Pure orchestration over the face-recognition
49
- * APIs:
50
- * - faceEventUuid -> searchSimilarFaces (appearance match; works for unrecognized people)
51
- * - personUuid -> getFaceEvents filtered to that person
52
- * - personQuery -> resolve name against registered faces, then getFaceEvents
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.
53
21
  */
54
22
  export async function getPersonTrack(args, timeZone, requestModifiers, sessionId) {
55
- let resolvedPerson;
56
- let raw = [];
57
- if (args.faceEventUuid) {
58
- // Appearance-based track from a seed sighting — handles people who aren't registered/named.
59
- const similar = await searchSimilarFaces(args.faceEventUuid, timeZone, requestModifiers, sessionId);
60
- raw = similar.map((s) => ({
61
- deviceUuid: s.deviceUuid,
62
- timestampMs: s.eventTimestampMs,
63
- datetime: s.eventTimestamp,
64
- similarity: s.similarity,
65
- faceName: undefined,
66
- }));
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
+ };
67
91
  }
68
- else {
69
- // Resolve to a person UUID (directly or by name) before pulling their face events.
70
- let personUuid = args.personUuid;
71
- if (!personUuid && args.personQuery) {
72
- const peopleResponse = await getRegisteredFaces({}, requestModifiers, sessionId);
73
- const matches = matchRegisteredPeople(args.personQuery, peopleResponse.people ?? []);
74
- if (matches.length > 1) {
75
- return { ambiguousPeople: matches, sightings: [], path: [], count: 0 };
76
- }
77
- if (matches.length === 0) {
78
- return { sightings: [], path: [], count: 0 };
79
- }
80
- resolvedPerson = matches[0];
81
- personUuid = matches[0].personUuid;
82
- }
83
- else if (personUuid) {
84
- resolvedPerson = { personUuid };
85
- }
86
- if (!personUuid) {
87
- return { sightings: [], path: [], count: 0 };
88
- }
89
- const faceEventArgs = {
90
- pageRequest: { maxPageSize: args.limit ?? 200, lastEvaluatedKey: null },
91
- searchFilter: {
92
- faceNameContains: null,
93
- faceNames: [],
94
- hasEmbedding: null,
95
- hasName: null,
96
- labels: [],
97
- locationUuids: args.locationUuids ?? [],
98
- personUuids: [personUuid],
99
- timestampFilter: args.afterMs != null || args.beforeMs != null
100
- ? {
101
- rangeStart: args.afterMs != null ? new Date(args.afterMs).toISOString() : null,
102
- rangeEnd: args.beforeMs != null ? new Date(args.beforeMs).toISOString() : null,
103
- }
104
- : null,
105
- },
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.",
106
102
  };
107
- const { faceEvents } = await getFaceEvents(faceEventArgs, timeZone, requestModifiers, sessionId);
108
- raw = faceEvents.map((e) => ({
109
- deviceUuid: e.deviceUuid,
110
- timestampMs: e.eventTimestampMs,
111
- datetime: e.eventTimestamp,
112
- locationUuid: e.locationUuid,
113
- faceName: e.faceName,
114
- thumbnailS3Key: e.thumbnailS3Key,
115
- }));
116
- if (!resolvedPerson?.name) {
117
- const named = raw.find((r) => r.faceName);
118
- if (named?.faceName)
119
- resolvedPerson = { name: named.faceName, personUuid };
120
- }
121
103
  }
122
- // Always enforce the time window client-side (searchSimilarFaces has no time filter, and the face-event
123
- // time filter defaults to the last 7 days if unset) and order chronologically.
124
- const ordered = raw
125
- .filter((r) => r.timestampMs != null)
126
- .filter((r) => (args.afterMs == null || r.timestampMs >= args.afterMs))
127
- .filter((r) => (args.beforeMs == null || r.timestampMs <= args.beforeMs))
128
- .sort((a, b) => (a.timestampMs ?? 0) - (b.timestampMs ?? 0))
129
- .slice(0, args.limit ?? 200);
130
- const sightings = buildSightings(ordered, timeZone, args.clipPaddingSeconds);
131
- // Camera sequence the person moved through, collapsing consecutive repeats.
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
+ });
132
137
  const path = [];
133
138
  for (const s of sightings) {
134
139
  if (s.deviceUuid && s.deviceUuid !== path[path.length - 1])
@@ -136,30 +141,10 @@ export async function getPersonTrack(args, timeZone, requestModifiers, sessionId
136
141
  }
137
142
  return {
138
143
  resolvedPerson,
144
+ anchor: anchorOut,
139
145
  sightings,
140
146
  path,
141
147
  lastKnownSighting: sightings[sightings.length - 1],
142
148
  count: sightings.length,
143
149
  };
144
150
  }
145
- function buildSightings(ordered, timeZone, clipPaddingSeconds) {
146
- return ordered.map((s, i) => {
147
- const next = ordered[i + 1];
148
- const gapToNextSeconds = next?.timestampMs != null && s.timestampMs != null
149
- ? Math.round((next.timestampMs - s.timestampMs) / 1000)
150
- : undefined;
151
- const { clipHint, stillHint } = buildMediaHints({ deviceUuid: s.deviceUuid, timestampMs: s.timestampMs }, clipPaddingSeconds);
152
- return {
153
- timestampMs: s.timestampMs,
154
- datetime: s.datetime ?? (s.timestampMs != null ? formatTimestamp(s.timestampMs, timeZone) : undefined),
155
- deviceUuid: s.deviceUuid,
156
- locationUuid: s.locationUuid,
157
- faceName: s.faceName,
158
- similarity: s.similarity,
159
- thumbnailS3Key: s.thumbnailS3Key,
160
- clipHint,
161
- stillHint,
162
- gapToNextSeconds,
163
- };
164
- });
165
- }
@@ -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
+ }
@@ -3,28 +3,25 @@ import { OUTPUT_SCHEMA, TOOL_ARGS } from "../types/person-tracking-tool-types.js
3
3
  import { createToolStructuredContent, extractFromToolExtra } from "../util.js";
4
4
  const TOOL_NAME = "person-tracking-tool";
5
5
  const TOOL_DESCRIPTION = `
6
- Tracks one person's movements across cameras using face recognition. Use this for "track this person",
7
- "where did X go", "follow this person across the building", or "where was X last seen" the camera-based
8
- counterpart to the badge-timeline tools (which follow door taps).
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
9
 
10
- Identify the person ONE of three ways (in priority order):
11
- - faceEventUuid: track by APPEARANCE from a specific sighting works even for an unrecognized/unnamed
12
- person (e.g. follow the person in this face event). Get a faceEventUuid from the faces-tool.
13
- - personUuid: the exact registered-person UUID (from faces-tool get-registered-faces).
14
- - personQuery: a full-text name (e.g. "Eve" or "Eve Adams"); it is resolved against the registered-faces
15
- directory. If "ambiguousPeople" is returned the name matched more than one person — ask the user which
16
- one before trusting the track.
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.
17
15
 
18
- Returns the person's sightings in CHRONOLOGICAL order (oldest first), each with:
19
- - datetime / timestampMs, deviceUuid (the camera), and locationUuid
20
- - similarity (only on appearance/faceEventUuid tracks) and a face thumbnail key
21
- - clipHint (camera + start/end window) and stillHint (camera + timestamp)
22
- - gapToNextSeconds: time until the next sighting (a large gap = unobserved movement between cameras)
23
- plus a "path" array (the camera sequence, consecutive repeats collapsed) and "lastKnownSighting" (the
24
- person's last-known location).
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.
25
22
 
26
23
  Resolve relative times like "yesterday" to ISO 8601 first (use the timestamp tool), then pass
27
- startTime/endTime. Face tracking depends on face-recognition coverage, so treat the track as investigative.
24
+ startTime/endTime. Re-id depends on human-detection coverage, so treat the track as investigative, not proof.
28
25
 
29
26
  IMPORTANT — to show the movement visually: for each sighting (or the key transitions), call the camera-tool
30
27
  (requestType "image", cameraUuid = sighting.deviceUuid, timestamp = sighting.timestampMs) for a still, and/or
@@ -35,12 +32,11 @@ const TOOL_HANDLER = async (args, _extra) => {
35
32
  const { requestModifiers, sessionId } = extractFromToolExtra(_extra);
36
33
  try {
37
34
  const result = await getPersonTrack({
38
- personQuery: args.personQuery ?? undefined,
39
- personUuid: args.personUuid ?? undefined,
40
- faceEventUuid: args.faceEventUuid ?? undefined,
41
- locationUuids: args.locationUuids ?? undefined,
35
+ personQuery: args.personQuery,
42
36
  afterMs: args.startTime ? new Date(args.startTime).getTime() : undefined,
43
37
  beforeMs: args.endTime ? new Date(args.endTime).getTime() : undefined,
38
+ locationUuids: args.locationUuids ?? undefined,
39
+ badgeMatchWindowSeconds: args.badgeMatchWindowSeconds ?? undefined,
44
40
  clipPaddingSeconds: args.clipPaddingSeconds ?? undefined,
45
41
  limit: args.limit ?? undefined,
46
42
  }, args.timeZone ?? "UTC", requestModifiers, sessionId);
@@ -4,32 +4,25 @@ import { ISOTimestampFormatDescription } from "../utils/timestampInput.js";
4
4
  export const TOOL_ARGS = {
5
5
  personQuery: z
6
6
  .string()
7
- .nullable()
8
- .describe('The recognized person to track, full-text name match against registered faces, e.g. "Eve" or "Eve Adams". ' +
9
- "Provide this OR personUuid OR faceEventUuid."),
10
- personUuid: z
11
- .string()
12
- .nullable()
13
- .describe("The exact person UUID to track (from faces-tool get-registered-faces). Takes precedence over personQuery."),
14
- faceEventUuid: z
15
- .string()
16
- .nullable()
17
- .describe("Track by appearance from a specific face sighting (a faceEvent UUID), even when the person isn't a registered/named face. " +
18
- "Use this for 'track THIS person' from a sighting. Takes precedence over personQuery/personUuid."),
19
- locationUuids: z
20
- .array(createUuidSchema())
21
- .nullable()
22
- .describe("Optional: restrict to these Rhombus location UUIDs. Use the location-tool to resolve names."),
7
+ .describe('The person to track, full-text name match against their access-control (badge) records, e.g. "Brandon" or "Brandon Salzberg".'),
23
8
  startTime: z
24
9
  .string()
25
10
  .datetime({ message: "Invalid datetime string. Expected ISO 8601 format.", offset: true })
26
11
  .nullable()
27
- .describe("Start of the window (inclusive). " + ISOTimestampFormatDescription),
12
+ .describe("Start of the window to search badge taps and track over (inclusive). " + ISOTimestampFormatDescription),
28
13
  endTime: z
29
14
  .string()
30
15
  .datetime({ message: "Invalid datetime string. Expected ISO 8601 format.", offset: true })
31
16
  .nullable()
32
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)."),
33
26
  clipPaddingSeconds: z
34
27
  .number()
35
28
  .nullable()
@@ -42,36 +35,22 @@ export const TOOL_ARGS = {
42
35
  };
43
36
  const TOOL_ARGS_SCHEMA = z.object(TOOL_ARGS);
44
37
  const ClipHintSchema = z
45
- .object({
46
- deviceUuid: z.string(),
47
- startTimeMs: z.number(),
48
- endTimeMs: z.number(),
49
- })
38
+ .object({ deviceUuid: z.string(), startTimeMs: z.number(), endTimeMs: z.number() })
50
39
  .describe("Pass to clips-tool createClip to get video of this sighting.");
51
40
  const StillHintSchema = z
52
- .object({
53
- deviceUuid: z.string(),
54
- timestampMs: z.number(),
55
- })
41
+ .object({ deviceUuid: z.string(), timestampMs: z.number() })
56
42
  .describe("Pass to camera-tool (requestType image) to get a still of this sighting.");
57
- const PersonRefSchema = z.object({
58
- name: z.string().optional(),
59
- personUuid: z.string().optional(),
60
- });
61
43
  export const SightingSchema = z.object({
62
44
  timestampMs: z.number().optional(),
63
45
  datetime: z.string().optional().describe("Human-readable sighting time in the requested timezone."),
64
- deviceUuid: z
65
- .string()
66
- .optional()
67
- .describe("The camera that saw the person. Pass to camera-tool (image) or clips-tool (createClip)."),
68
- locationUuid: z.string().optional().describe("The location where the sighting occurred."),
69
- faceName: z.string().optional().describe("The recognized name on this sighting, if any."),
70
- similarity: z
46
+ deviceUuid: z.string().optional().describe("The camera that re-identified the person."),
47
+ locationUuid: z.string().optional(),
48
+ distance: z
71
49
  .number()
72
50
  .optional()
73
- .describe("Appearance-match similarity (0-1) when tracking from a faceEventUuid seed."),
74
- thumbnailS3Key: z.string().optional().describe("Thumbnail key for the detected face on this sighting."),
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."),
75
54
  clipHint: ClipHintSchema.optional(),
76
55
  stillHint: StillHintSchema.optional(),
77
56
  gapToNextSeconds: z
@@ -79,21 +58,31 @@ export const SightingSchema = z.object({
79
58
  .optional()
80
59
  .describe("Seconds until the next sighting — large gaps mean the person was unobserved between cameras."),
81
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).");
82
70
  export const OUTPUT_SCHEMA = z.object({
83
- resolvedPerson: PersonRefSchema.optional().describe("The person actually tracked (name + UUID), when resolved."),
84
- ambiguousPeople: z
85
- .array(PersonRefSchema)
71
+ resolvedPerson: z
72
+ .object({ name: z.string().optional() })
86
73
  .optional()
87
- .describe("Set when personQuery matched more than one registered person disambiguate with the user before trusting the track."),
74
+ .describe("The person resolved from the badge record."),
75
+ anchor: AnchorSchema.optional(),
88
76
  sightings: z
89
77
  .array(SightingSchema)
90
78
  .optional()
91
- .describe("The person's camera sightings in chronological order (oldest first)."),
79
+ .describe("Re-id sightings of the person across cameras, in chronological order (oldest first)."),
92
80
  path: z
93
81
  .array(z.string())
94
82
  .optional()
95
- .describe("Camera UUIDs the person passed through, in order (consecutive repeats collapsed)."),
96
- lastKnownSighting: SightingSchema.optional().describe("The most recent sighting — the person's last-known location."),
97
- count: z.number().optional().describe("Number of sightings returned."),
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)."),
98
87
  error: z.string().optional(),
99
88
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rhombus-node-mcp",
3
- "version": "0.1.54",
3
+ "version": "0.1.55",
4
4
  "description": "MCP server for Rhombus API",
5
5
  "keywords": [
6
6
  "ai",