rhombus-node-mcp 0.1.52 → 0.1.54

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,165 @@
1
+ import { buildMediaHints } from "./badge-correlation.js";
2
+ import { getFaceEvents, getRegisteredFaces, searchSimilarFaces } from "./faces-tool-api.js";
3
+ 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;
45
+ }
46
+ /**
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
53
+ */
54
+ 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
+ }));
67
+ }
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
+ },
106
+ };
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
+ }
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.
132
+ const path = [];
133
+ for (const s of sightings) {
134
+ if (s.deviceUuid && s.deviceUuid !== path[path.length - 1])
135
+ path.push(s.deviceUuid);
136
+ }
137
+ return {
138
+ resolvedPerson,
139
+ sightings,
140
+ path,
141
+ lastKnownSighting: sightings[sightings.length - 1],
142
+ count: sightings.length,
143
+ };
144
+ }
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
+ }
@@ -6,6 +6,14 @@ const TOOL_DESCRIPTION = `
6
6
  Searches Honeywell Elements (LenelS2 Elements) badge / access-control events for the organization. Use this to answer
7
7
  "who entered WHERE and WHEN" questions, e.g. "who entered the back office yesterday".
8
8
 
9
+ NOTE: an organization may run any combination of Honeywell OnGuard (Lenel), Honeywell Elements (LenelS2
10
+ Elements), and Lenel S2 NetBox badge integrations — each searched by its own sibling tool
11
+ (onguard-events-tool / elements-events-tool / netbox-events-tool), all taking identical arguments and
12
+ returning the same shape. For a general "who badged in / did anyone enter" question you usually do NOT
13
+ know which integration recorded the event, so call ALL THREE sibling tools (in parallel) and combine the
14
+ results — each returns an empty list when its integration isn't configured. Restrict to one vendor only
15
+ when the user explicitly names it.
16
+
9
17
  Each returned event includes:
10
18
  - cardholderName: the person's name
11
19
  - deviceUuid: the camera that saw the event
@@ -6,6 +6,14 @@ const TOOL_DESCRIPTION = `
6
6
  Searches Lenel S2 NetBox (Honeywell NetBox) badge / access-control events for the organization. Use this to answer
7
7
  "who entered WHERE and WHEN" questions, e.g. "who entered the back office yesterday".
8
8
 
9
+ NOTE: an organization may run any combination of Honeywell OnGuard (Lenel), Honeywell Elements (LenelS2
10
+ Elements), and Lenel S2 NetBox badge integrations — each searched by its own sibling tool
11
+ (onguard-events-tool / elements-events-tool / netbox-events-tool), all taking identical arguments and
12
+ returning the same shape. For a general "who badged in / did anyone enter" question you usually do NOT
13
+ know which integration recorded the event, so call ALL THREE sibling tools (in parallel) and combine the
14
+ results — each returns an empty list when its integration isn't configured. Restrict to one vendor only
15
+ when the user explicitly names it.
16
+
9
17
  Each returned event includes:
10
18
  - cardholderName: the person's name
11
19
  - deviceUuid: the camera that saw the event
@@ -6,6 +6,14 @@ const TOOL_DESCRIPTION = `
6
6
  Searches Honeywell OnGuard (Lenel) badge / access-control events for the organization. Use this to answer
7
7
  "who entered WHERE and WHEN" questions, e.g. "who entered the back office yesterday".
8
8
 
9
+ NOTE: an organization may run any combination of Honeywell OnGuard (Lenel), Honeywell Elements (LenelS2
10
+ Elements), and Lenel S2 NetBox badge integrations — each searched by its own sibling tool
11
+ (onguard-events-tool / elements-events-tool / netbox-events-tool), all taking identical arguments and
12
+ returning the same shape. For a general "who badged in / did anyone enter" question you usually do NOT
13
+ know which integration recorded the event, so call ALL THREE sibling tools (in parallel) and combine the
14
+ results — each returns an empty list when its integration isn't configured. Restrict to one vendor only
15
+ when the user explicitly names it.
16
+
9
17
  Each returned event includes:
10
18
  - cardholderName: the person's name
11
19
  - deviceUuid: the camera that saw the event
@@ -0,0 +1,60 @@
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
+ 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).
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.
17
+
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).
25
+
26
+ 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.
28
+
29
+ IMPORTANT — to show the movement visually: for each sighting (or the key transitions), call the camera-tool
30
+ (requestType "image", cameraUuid = sighting.deviceUuid, timestamp = sighting.timestampMs) for a still, and/or
31
+ the clips-tool (requestType "createClip", using sighting.clipHint) for video. Issue those per-sighting media
32
+ calls in PARALLEL, then present the track as a chronological narrative.
33
+ `;
34
+ const TOOL_HANDLER = async (args, _extra) => {
35
+ const { requestModifiers, sessionId } = extractFromToolExtra(_extra);
36
+ try {
37
+ const result = await getPersonTrack({
38
+ personQuery: args.personQuery ?? undefined,
39
+ personUuid: args.personUuid ?? undefined,
40
+ faceEventUuid: args.faceEventUuid ?? undefined,
41
+ locationUuids: args.locationUuids ?? undefined,
42
+ afterMs: args.startTime ? new Date(args.startTime).getTime() : undefined,
43
+ beforeMs: args.endTime ? new Date(args.endTime).getTime() : undefined,
44
+ clipPaddingSeconds: args.clipPaddingSeconds ?? undefined,
45
+ limit: args.limit ?? undefined,
46
+ }, args.timeZone ?? "UTC", requestModifiers, sessionId);
47
+ return createToolStructuredContent(result);
48
+ }
49
+ catch (error) {
50
+ const message = error instanceof Error ? error.message : "Unknown error";
51
+ return createToolStructuredContent({ error: message });
52
+ }
53
+ };
54
+ export function createTool(server) {
55
+ server.registerTool(TOOL_NAME, {
56
+ description: TOOL_DESCRIPTION,
57
+ inputSchema: TOOL_ARGS,
58
+ outputSchema: OUTPUT_SCHEMA.shape,
59
+ }, TOOL_HANDLER);
60
+ }
@@ -0,0 +1,99 @@
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
+ .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."),
23
+ startTime: z
24
+ .string()
25
+ .datetime({ message: "Invalid datetime string. Expected ISO 8601 format.", offset: true })
26
+ .nullable()
27
+ .describe("Start of the window (inclusive). " + ISOTimestampFormatDescription),
28
+ endTime: z
29
+ .string()
30
+ .datetime({ message: "Invalid datetime string. Expected ISO 8601 format.", offset: true })
31
+ .nullable()
32
+ .describe("End of the window (inclusive). " + ISOTimestampFormatDescription),
33
+ clipPaddingSeconds: z
34
+ .number()
35
+ .nullable()
36
+ .describe("Seconds of video before/after each sighting to include in the clip hint (default 15)."),
37
+ limit: z.number().nullable().describe("Maximum sightings to include (default 200)."),
38
+ timeZone: z
39
+ .string()
40
+ .nullable()
41
+ .describe("IANA timezone used to format times, e.g. America/New_York. Defaults to UTC."),
42
+ };
43
+ const TOOL_ARGS_SCHEMA = z.object(TOOL_ARGS);
44
+ const ClipHintSchema = z
45
+ .object({
46
+ deviceUuid: z.string(),
47
+ startTimeMs: z.number(),
48
+ endTimeMs: z.number(),
49
+ })
50
+ .describe("Pass to clips-tool createClip to get video of this sighting.");
51
+ const StillHintSchema = z
52
+ .object({
53
+ deviceUuid: z.string(),
54
+ timestampMs: z.number(),
55
+ })
56
+ .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
+ export const SightingSchema = z.object({
62
+ timestampMs: z.number().optional(),
63
+ 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
71
+ .number()
72
+ .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."),
75
+ clipHint: ClipHintSchema.optional(),
76
+ stillHint: StillHintSchema.optional(),
77
+ gapToNextSeconds: z
78
+ .number()
79
+ .optional()
80
+ .describe("Seconds until the next sighting — large gaps mean the person was unobserved between cameras."),
81
+ });
82
+ 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)
86
+ .optional()
87
+ .describe("Set when personQuery matched more than one registered person — disambiguate with the user before trusting the track."),
88
+ sightings: z
89
+ .array(SightingSchema)
90
+ .optional()
91
+ .describe("The person's camera sightings in chronological order (oldest first)."),
92
+ path: z
93
+ .array(z.string())
94
+ .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."),
98
+ error: z.string().optional(),
99
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rhombus-node-mcp",
3
- "version": "0.1.52",
3
+ "version": "0.1.54",
4
4
  "description": "MCP server for Rhombus API",
5
5
  "keywords": [
6
6
  "ai",