rhombus-node-mcp 0.1.56 → 0.1.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/dist/api/access-control-tool-api.js +231 -32
  2. package/dist/api/alarm-monitoring-tool-api.js +50 -1
  3. package/dist/api/automated-prompts-tool-api.js +9 -17
  4. package/dist/api/camera-policy-tool-api.js +122 -0
  5. package/dist/api/camera-tool-api.js +3 -5
  6. package/dist/api/camera-uptime-tool-api.js +62 -25
  7. package/dist/api/clips-tool-api.js +45 -9
  8. package/dist/api/create-tool-api.js +55 -7
  9. package/dist/api/door-tool-api.js +74 -13
  10. package/dist/api/entity-lookup-tool-api.js +8 -1
  11. package/dist/api/events-tool-api.js +79 -6
  12. package/dist/api/faces-tool-api.js +112 -7
  13. package/dist/api/get-accessible-apps.js +68 -6
  14. package/dist/api/get-entity-tool-api.js +12 -12
  15. package/dist/api/get-org-information-tool-api.js +2 -2
  16. package/dist/api/guest-management-tool-api.js +75 -1
  17. package/dist/api/location-tool-api.js +120 -28
  18. package/dist/api/lpr-tool-api.js +71 -16
  19. package/dist/api/policy-alerts-tool-api.js +7 -3
  20. package/dist/api/report-tool-api.js +4 -1
  21. package/dist/api/rules-tool-api.js +6 -16
  22. package/dist/api/schedule-tool-api.js +107 -0
  23. package/dist/api/search-tool-api.js +5 -13
  24. package/dist/api/update-tool-api.js +96 -1
  25. package/dist/api/user-tool-api.js +58 -13
  26. package/dist/createServer.js +18 -6
  27. package/dist/filtering-utils.js +421 -29
  28. package/dist/index.js +1 -1
  29. package/dist/network/network.js +122 -10
  30. package/dist/network/org-reference-cache.js +74 -0
  31. package/dist/telemetry/tracingProxy.js +4 -0
  32. package/dist/tools/entity-lookup-tool.js +6 -4
  33. package/dist/tools/get-entity-tool.js +40 -20
  34. package/dist/tools/get-org-information-tool.js +30 -2
  35. package/dist/tools/time-conversion-tool.js +59 -36
  36. package/dist/tools/time-tool.js +12 -3
  37. package/dist/tools/user-tool.js +100 -2
  38. package/dist/tools-console/access-anomaly-tool.js +2 -19
  39. package/dist/tools-console/access-control-tool.js +328 -2
  40. package/dist/tools-console/alarm-monitoring-tool.js +72 -2
  41. package/dist/tools-console/analytics-tool.js +2 -2
  42. package/dist/tools-console/badge-timeline-tool.js +2 -21
  43. package/dist/tools-console/camera-policy-tool.js +252 -0
  44. package/dist/tools-console/camera-tool.js +111 -89
  45. package/dist/tools-console/clips-tool.js +21 -31
  46. package/dist/tools-console/count-tool.js +8 -0
  47. package/dist/tools-console/create-camera-policy-tool.js +302 -122
  48. package/dist/tools-console/door-schedule-exception-tool.js +2 -33
  49. package/dist/tools-console/door-tool.js +130 -2
  50. package/dist/tools-console/elements-access-anomaly-tool.js +2 -19
  51. package/dist/tools-console/elements-badge-timeline-tool.js +2 -21
  52. package/dist/tools-console/elements-lost-badge-tool.js +2 -17
  53. package/dist/tools-console/elements-tool.js +2 -29
  54. package/dist/tools-console/events-tool.js +22 -101
  55. package/dist/tools-console/faces-tool.js +254 -28
  56. package/dist/tools-console/guest-management-tool.js +127 -2
  57. package/dist/tools-console/location-tool.js +156 -35
  58. package/dist/tools-console/lost-badge-tool.js +2 -17
  59. package/dist/tools-console/lpr-tool.js +89 -23
  60. package/dist/tools-console/netbox-access-anomaly-tool.js +2 -19
  61. package/dist/tools-console/netbox-badge-timeline-tool.js +2 -21
  62. package/dist/tools-console/netbox-lost-badge-tool.js +2 -17
  63. package/dist/tools-console/netbox-tool.js +2 -29
  64. package/dist/tools-console/onguard-tool.js +2 -30
  65. package/dist/tools-console/person-tracking-tool.js +2 -2
  66. package/dist/tools-console/policy-alerts-tool.js +5 -29
  67. package/dist/tools-console/reboot-cameras-tool.js +17 -2
  68. package/dist/tools-console/report-tool.js +10 -40
  69. package/dist/tools-console/schedule-tool.js +190 -0
  70. package/dist/tools-console/update-tool.js +298 -168
  71. package/dist/tools-console/video-walls-tool.js +93 -21
  72. package/dist/types/access-anomaly-tool-types.js +22 -2
  73. package/dist/types/access-control-tool-types.js +116 -1
  74. package/dist/types/alarm-monitoring-tool-types.js +42 -1
  75. package/dist/types/badge-timeline-tool-types.js +18 -3
  76. package/dist/types/camera-policy-tool-types.js +104 -0
  77. package/dist/types/camera-tool-types.js +80 -1
  78. package/dist/types/clips-tool-types.js +27 -3
  79. package/dist/types/create-camera-policy-tool-types.js +275 -26
  80. package/dist/types/door-schedule-exception-tool-types.js +20 -6
  81. package/dist/types/door-tool-types.js +36 -4
  82. package/dist/types/entity-lookup-tool-types.js +6 -1
  83. package/dist/types/events-tools-types.js +24 -16
  84. package/dist/types/faces-tools-types.js +106 -3
  85. package/dist/types/get-entity-tool-types.js +76 -12
  86. package/dist/types/guest-management-tool-types.js +103 -1
  87. package/dist/types/location-tool-types.js +100 -14
  88. package/dist/types/lost-badge-tool-types.js +17 -2
  89. package/dist/types/lpr-tool-types.js +47 -8
  90. package/dist/types/onguard-tool-types.js +21 -1
  91. package/dist/types/policy-alerts-tool-types.js +1 -1
  92. package/dist/types/report-tool-types.js +29 -1
  93. package/dist/types/schedule-tool-types.js +370 -0
  94. package/dist/types/schema.js +479 -9
  95. package/dist/types/time-conversion-tool-types.js +3 -4
  96. package/dist/types/update-tool-types.js +40 -18
  97. package/dist/types/user-tool-types.js +44 -1
  98. package/dist/types/video-walls-tool-types.js +30 -2
  99. package/dist/types/zod-schemas.js +5128 -996
  100. package/dist/util.js +62 -3
  101. package/dist/utils/entity-name-match.js +11 -0
  102. package/package.json +3 -1
@@ -1,28 +1,9 @@
1
1
  import { getBadgeTimeline } from "../api/badge-timeline-tool-api.js";
2
2
  import { OUTPUT_SCHEMA, TOOL_ARGS } from "../types/badge-timeline-tool-types.js";
3
3
  import { createToolStructuredContent, extractFromToolExtra } from "../util.js";
4
+ import { buildBadgeTimelineToolDescription } from "../types/badge-timeline-tool-types.js";
4
5
  const TOOL_NAME = "badge-timeline-tool";
5
- const TOOL_DESCRIPTION = `
6
- Reconstructs one person's movements through a building from their Honeywell OnGuard (Lenel) badge taps.
7
- Use this for incident reconstruction / "follow the badge" requests, e.g. "reconstruct Eve's movements
8
- yesterday" or "where did this cardholder go".
9
-
10
- Returns the cardholder's badge taps in CHRONOLOGICAL order (oldest first), each with:
11
- - datetime / timestampMs and the area entered
12
- - deviceUuid: the camera at that door
13
- - clipHint (camera + start/end window) and stillHint (camera + timestamp)
14
- - gapToNextSeconds: time until the next tap (a large gap = unobserved movement between doors)
15
- plus a "path" array summarizing the areas traversed in order.
16
-
17
- Resolve relative times like "yesterday" to ISO 8601 first (use the timestamp tool), then pass
18
- startTime/endTime. cardholderQuery is a full-text name match; if "ambiguousCardholders" is returned the
19
- query matched more than one person — ask the user which one before trusting the timeline.
20
-
21
- IMPORTANT — to show the movement visually: for each stop (or the key transitions), call the camera-tool
22
- (requestType "image", cameraUuid = stop.deviceUuid, timestamp = stop.timestampMs) for a still you can see,
23
- and/or the clips-tool (requestType "createClip", using stop.clipHint) for video. Issue those per-stop
24
- media calls in PARALLEL, then present the timeline as a chronological narrative.
25
- `;
6
+ const TOOL_DESCRIPTION = buildBadgeTimelineToolDescription("Honeywell OnGuard (Lenel)");
26
7
  const TOOL_HANDLER = async (args, _extra) => {
27
8
  const { requestModifiers, sessionId } = extractFromToolExtra(_extra);
28
9
  try {
@@ -0,0 +1,252 @@
1
+ import { deleteCameraPolicy, getCameraPolicies, getCamerasByPolicy, setPolicyOnCameras, updateCameraPolicy, } from "../api/camera-policy-tool-api.js";
2
+ import { CameraPolicyToolRequestType, OUTPUT_SCHEMA, TOOL_ARGS, } from "../types/camera-policy-tool-types.js";
3
+ import { parseCameraUuids, parseScheduleConfigs, } from "../types/create-camera-policy-tool-types.js";
4
+ import { createToolStructuredContent, createToolTextContent, extractFromToolExtra, } from "../util.js";
5
+ const TOOL_NAME = "camera-policy-tool";
6
+ const TOOL_DESCRIPTION = `
7
+ This tool reads and manages EXISTING camera alert policies — the rules that decide which camera activities raise an alert, and when.
8
+
9
+ To CREATE a new camera policy, use create-camera-policy-tool instead. This tool covers everything after creation.
10
+
11
+ It has the following modes of operation, determined by the "requestType" parameter:
12
+ - ${CameraPolicyToolRequestType.LIST}: List every camera policy with its schedules, trigger activities, and the cameras assigned to it.
13
+ - ${CameraPolicyToolRequestType.GET}: Get one policy in full. Requires policyUuid.
14
+ - ${CameraPolicyToolRequestType.UPDATE}: Rename a policy, change its description, and/or replace its schedule triggers. Requires policyUuid plus at least one of name, description, scheduleConfigs.
15
+ - ${CameraPolicyToolRequestType.DELETE}: Delete a policy. Requires policyUuid. Refuses (without deleting) while cameras are still assigned to it, and lists them — see confirmDeleteInUse.
16
+ - ${CameraPolicyToolRequestType.ASSIGN_CAMERAS}: Assign the policy to cameras. Requires policyUuid and cameraUuids.
17
+ - ${CameraPolicyToolRequestType.UNASSIGN_CAMERAS}: Remove the policy from cameras, leaving them with no policy. Requires policyUuid and cameraUuids.
18
+
19
+ A camera has at most ONE policy, so assigning a policy to a camera replaces whatever policy it had before. Say so when a camera already has one.
20
+ `;
21
+ /**
22
+ * Join the camera assignments onto a policy. The camera holds the policyUuid, so
23
+ * the count is pre-computed here rather than left for the model to derive by
24
+ * scanning rows — under-enumeration of long tool results is a known failure.
25
+ */
26
+ function withAssignments(policy, byPolicy) {
27
+ const cameras = (policy.uuid && byPolicy.get(policy.uuid)) || [];
28
+ return { ...policy, assignedCameraCount: cameras.length, assignedCameras: cameras };
29
+ }
30
+ const TOOL_HANDLER = async (args, extra) => {
31
+ const { requestModifiers, sessionId } = extractFromToolExtra(extra);
32
+ try {
33
+ switch (args.requestType) {
34
+ case CameraPolicyToolRequestType.LIST: {
35
+ const [{ policies, warningMsg }, byPolicy] = await Promise.all([
36
+ getCameraPolicies(requestModifiers, sessionId),
37
+ getCamerasByPolicy(requestModifiers, sessionId),
38
+ ]);
39
+ const enriched = policies.map(policy => withAssignments(policy, byPolicy));
40
+ const unassigned = enriched.filter(policy => policy.assignedCameraCount === 0);
41
+ return createToolStructuredContent({
42
+ policies: enriched,
43
+ warningMsg,
44
+ note: enriched.length === 0
45
+ ? "This organization has no camera policies. Use create-camera-policy-tool to add one."
46
+ : unassigned.length > 0
47
+ ? `${unassigned.length} of ${enriched.length} ${unassigned.length === 1 ? "policy is" : "policies are"} assigned to no cameras and therefore never fire: ${unassigned.map(policy => policy.name ?? policy.uuid).join(", ")}.`
48
+ : undefined,
49
+ });
50
+ }
51
+ case CameraPolicyToolRequestType.GET: {
52
+ if (!args.policyUuid) {
53
+ return createToolTextContent("policyUuid is required for 'get'.");
54
+ }
55
+ const [{ policies }, byPolicy] = await Promise.all([
56
+ getCameraPolicies(requestModifiers, sessionId),
57
+ getCamerasByPolicy(requestModifiers, sessionId),
58
+ ]);
59
+ const policy = policies.find(entry => entry.uuid === args.policyUuid);
60
+ if (!policy) {
61
+ return createToolTextContent(`No camera policy in this organization has the uuid "${args.policyUuid}". Use requestType 'list' to see the policies that exist — do not guess a uuid.`);
62
+ }
63
+ return createToolStructuredContent({
64
+ policy: withAssignments(policy, byPolicy),
65
+ });
66
+ }
67
+ case CameraPolicyToolRequestType.UPDATE: {
68
+ if (!args.policyUuid) {
69
+ return createToolTextContent("policyUuid is required for 'update'.");
70
+ }
71
+ if (!args.name?.trim() && !args.description?.trim() && !args.scheduleConfigs) {
72
+ return createToolTextContent("'update' needs at least one of name, description or scheduleConfigs — all were empty, so there is nothing to change.");
73
+ }
74
+ // Validate before reading anything, so a malformed input costs no calls.
75
+ let scheduledTriggers;
76
+ if (args.scheduleConfigs) {
77
+ const parsed = parseScheduleConfigs(args.scheduleConfigs);
78
+ if (!parsed.ok) {
79
+ return createToolTextContent(`RETRYABLE — nothing was changed. ${parsed.message}`);
80
+ }
81
+ scheduledTriggers = parsed.value.map(config => ({
82
+ scheduleUuid: config.scheduleUuid,
83
+ triggerSet: config.activities.map(activity => ({ activity })),
84
+ }));
85
+ }
86
+ // api2's updateCameraPolicy REPLACES the policy object, so sending only
87
+ // the changed field would blank the others. Read first and merge.
88
+ const { policies } = await getCameraPolicies(requestModifiers, sessionId);
89
+ const existing = policies.find(entry => entry.uuid === args.policyUuid);
90
+ if (!existing) {
91
+ return createToolTextContent(`No camera policy in this organization has the uuid "${args.policyUuid}". Use requestType 'list' to see the policies that exist.`);
92
+ }
93
+ const replacedTriggers = scheduledTriggers !== undefined;
94
+ const mergedTriggers = scheduledTriggers ??
95
+ existing.scheduledTriggers?.flatMap(trigger => trigger.scheduleUuid
96
+ ? [
97
+ {
98
+ scheduleUuid: trigger.scheduleUuid,
99
+ triggerSet: (trigger.activities ?? []).map(activity => ({
100
+ activity,
101
+ })),
102
+ },
103
+ ]
104
+ : []) ??
105
+ [];
106
+ const { updated, warningMsg } = await updateCameraPolicy({
107
+ uuid: args.policyUuid,
108
+ name: args.name?.trim() || existing.name,
109
+ description: args.description?.trim() || existing.description,
110
+ scheduledTriggers: mergedTriggers,
111
+ }, requestModifiers, sessionId);
112
+ const notes = [];
113
+ if (replacedTriggers) {
114
+ notes.push(`Replaced all schedule triggers — the policy now has ${mergedTriggers.length} ${mergedTriggers.length === 1 ? "trigger" : "triggers"}.`);
115
+ }
116
+ if (existing.scheduledTriggers?.length &&
117
+ !replacedTriggers &&
118
+ existing.scheduledTriggers.some(trigger => !trigger.scheduleUuid)) {
119
+ // A trigger with no resolvable scheduleUuid cannot be re-sent, so the
120
+ // caller has to know it was dropped rather than preserved.
121
+ notes.push("One or more existing schedule triggers had no resolvable schedule and could not be preserved by this update — re-check the policy's schedules.");
122
+ }
123
+ return createToolStructuredContent({
124
+ updated,
125
+ warningMsg,
126
+ note: notes.length > 0 ? notes.join(" ") : undefined,
127
+ });
128
+ }
129
+ case CameraPolicyToolRequestType.DELETE: {
130
+ if (!args.policyUuid) {
131
+ return createToolTextContent("policyUuid is required for 'delete'.");
132
+ }
133
+ const [{ policies }, byPolicy] = await Promise.all([
134
+ getCameraPolicies(requestModifiers, sessionId),
135
+ getCamerasByPolicy(requestModifiers, sessionId),
136
+ ]);
137
+ const existing = policies.find(entry => entry.uuid === args.policyUuid);
138
+ if (!existing) {
139
+ return createToolTextContent(`No camera policy in this organization has the uuid "${args.policyUuid}". Use requestType 'list' to see the policies that exist.`);
140
+ }
141
+ const assigned = byPolicy.get(args.policyUuid) ?? [];
142
+ if (assigned.length > 0 && !args.confirmDeleteInUse) {
143
+ return createToolStructuredContent({
144
+ policy: withAssignments(existing, byPolicy),
145
+ note: `NOT DELETED — nothing was changed. "${existing.name ?? args.policyUuid}" is still assigned to ` +
146
+ `${assigned.length} ${assigned.length === 1 ? "camera" : "cameras"}: ` +
147
+ `${assigned.map(camera => camera.name ?? camera.uuid).join(", ")}. Deleting it leaves ` +
148
+ `${assigned.length === 1 ? "that camera" : "those cameras"} with no alert policy at all. Tell the user which ` +
149
+ `cameras are affected and ask them to confirm, then call this tool again with confirmDeleteInUse: true.`,
150
+ });
151
+ }
152
+ const { deleted, warningMsg } = await deleteCameraPolicy(args.policyUuid, requestModifiers, sessionId);
153
+ return createToolStructuredContent({
154
+ deleted,
155
+ warningMsg,
156
+ note: assigned.length > 0
157
+ ? `Deleted "${existing.name ?? args.policyUuid}". ${assigned.length} ${assigned.length === 1 ? "camera" : "cameras"} (${assigned.map(camera => camera.name ?? camera.uuid).join(", ")}) now ${assigned.length === 1 ? "has" : "have"} no alert policy.`
158
+ : `Deleted "${existing.name ?? args.policyUuid}". No cameras were assigned to it.`,
159
+ });
160
+ }
161
+ case CameraPolicyToolRequestType.ASSIGN_CAMERAS:
162
+ case CameraPolicyToolRequestType.UNASSIGN_CAMERAS: {
163
+ const unassigning = args.requestType === CameraPolicyToolRequestType.UNASSIGN_CAMERAS;
164
+ if (!args.policyUuid) {
165
+ return createToolTextContent(`policyUuid is required for '${args.requestType}'.`);
166
+ }
167
+ if (!args.cameraUuids) {
168
+ return createToolTextContent(`cameraUuids is required for '${args.requestType}'.`);
169
+ }
170
+ const parsed = parseCameraUuids(args.cameraUuids);
171
+ if (!parsed.ok) {
172
+ return createToolTextContent(`RETRYABLE — nothing was changed. ${parsed.message}`);
173
+ }
174
+ const byPolicy = await getCamerasByPolicy(requestModifiers, sessionId);
175
+ if (unassigning) {
176
+ // Only clear cameras that actually carry THIS policy: writing an empty
177
+ // policyUuid to a camera on some other policy would silently strip
178
+ // that unrelated policy instead.
179
+ const onThisPolicy = new Set((byPolicy.get(args.policyUuid) ?? []).map(camera => camera.uuid));
180
+ const notOnPolicy = parsed.value.filter(uuid => !onThisPolicy.has(uuid));
181
+ const toClear = parsed.value.filter(uuid => onThisPolicy.has(uuid));
182
+ if (toClear.length === 0) {
183
+ return createToolStructuredContent({
184
+ note: `Nothing was changed — none of the ${parsed.value.length} listed ${parsed.value.length === 1 ? "camera is" : "cameras are"} assigned to this policy. Check the assignment with requestType 'get' first.`,
185
+ });
186
+ }
187
+ const { cameraCount, warningMsg } = await setPolicyOnCameras(toClear,
188
+ // null, not "" — see setPolicyOnCameras: api2 rejects an
189
+ // empty string for this RUUID-formatted field.
190
+ null, requestModifiers, sessionId);
191
+ return createToolStructuredContent({
192
+ assigned: { success: true, policyUuid: args.policyUuid, cameraCount },
193
+ warningMsg,
194
+ note: notOnPolicy.length > 0
195
+ ? `Removed the policy from ${cameraCount} ${cameraCount === 1 ? "camera" : "cameras"}, which now ${cameraCount === 1 ? "has" : "have"} no alert policy. SCOPE REDUCED: ${notOnPolicy.length} of the listed cameras were skipped because they were not on this policy (${notOnPolicy.join(", ")}) — their own policies were left alone.`
196
+ : `Removed the policy from ${cameraCount} ${cameraCount === 1 ? "camera" : "cameras"}, which now ${cameraCount === 1 ? "has" : "have"} no alert policy.`,
197
+ });
198
+ }
199
+ // Assigning replaces whatever policy a camera already had; name the
200
+ // displaced policies so the answer can say what changed.
201
+ const displaced = new Map();
202
+ for (const [policyUuid, cameras] of byPolicy) {
203
+ if (policyUuid === args.policyUuid)
204
+ continue;
205
+ for (const camera of cameras) {
206
+ if (camera.uuid && parsed.value.includes(camera.uuid)) {
207
+ const bucket = displaced.get(policyUuid) ?? [];
208
+ bucket.push(camera.name ?? camera.uuid);
209
+ displaced.set(policyUuid, bucket);
210
+ }
211
+ }
212
+ }
213
+ const { policies } = await getCameraPolicies(requestModifiers, sessionId);
214
+ const existing = policies.find(entry => entry.uuid === args.policyUuid);
215
+ if (!existing) {
216
+ return createToolTextContent(`No camera policy in this organization has the uuid "${args.policyUuid}". Use requestType 'list' to see the policies that exist — assigning a non-existent policy would leave the cameras with no policy at all.`);
217
+ }
218
+ const { cameraCount, warningMsg } = await setPolicyOnCameras(parsed.value, args.policyUuid, requestModifiers, sessionId);
219
+ const displacedNote = [...displaced.entries()]
220
+ .map(([policyUuid, cameras]) => {
221
+ const name = policies.find(entry => entry.uuid === policyUuid)?.name ?? policyUuid;
222
+ return `${cameras.join(", ")} (was on "${name}")`;
223
+ })
224
+ .join("; ");
225
+ return createToolStructuredContent({
226
+ assigned: { success: true, policyUuid: args.policyUuid, cameraCount },
227
+ warningMsg,
228
+ note: displacedNote
229
+ ? `Assigned "${existing.name ?? args.policyUuid}" to ${cameraCount} ${cameraCount === 1 ? "camera" : "cameras"}. This REPLACED the policy already on: ${displacedNote}.`
230
+ : `Assigned "${existing.name ?? args.policyUuid}" to ${cameraCount} ${cameraCount === 1 ? "camera" : "cameras"}.`,
231
+ });
232
+ }
233
+ }
234
+ }
235
+ catch (error) {
236
+ return createToolStructuredContent({
237
+ error: error instanceof Error ? error.message : "Unknown error",
238
+ });
239
+ }
240
+ return createToolStructuredContent({
241
+ error: `Invalid requestType. Valid values are: ${Object.values(CameraPolicyToolRequestType).join(", ")}.`,
242
+ });
243
+ };
244
+ export function createTool(server) {
245
+ server.registerTool(TOOL_NAME, {
246
+ title: "Camera Policies",
247
+ description: TOOL_DESCRIPTION,
248
+ inputSchema: TOOL_ARGS,
249
+ outputSchema: OUTPUT_SCHEMA.shape,
250
+ annotations: { readOnlyHint: false, destructiveHint: true },
251
+ }, TOOL_HANDLER);
252
+ }
@@ -1,73 +1,76 @@
1
1
  import { getCameraSettings, getImageForCameraAtTime, getCameraMediaUris, getCameraAIThresholds } from "../api/camera-tool-api.js";
2
2
  import { getLogger } from "../logger.js";
3
- import { BASE_TOOL_ARGS } from "../types/camera-tool-types.js";
3
+ import { BASE_TOOL_ARGS, OUTPUT_SCHEMA } from "../types/camera-tool-types.js";
4
4
  import { extractFromToolExtra } from "../util.js";
5
5
  const TOOL_NAME = "camera-tool";
6
+ // Tool descriptions are billed on EVERY LLM call even while the tool is
7
+ // deferred behind hosted tool_search, so this string carries only what the
8
+ // model needs to CHOOSE this tool (including the triggers that MUST route
9
+ // here). The step-by-step flows live on the requestType parameter description
10
+ // — unbilled until the tool is loaded, and in front of the model exactly when
11
+ // it is acting. See PERF_MASTER_PLAN P2 #4a.
6
12
  const TOOL_DESCRIPTION = `
7
- This tool can perform some action pertaining to the video stream of a camera. There are four types of requests
8
- that can be passed into "requestType":
9
- - image
10
- - get-settings
11
- - get-media-uris
12
- - get-ai-thresholds
13
+ Acts on a camera's video stream. Set "requestType":
14
+ - **image** a frame from the camera at a given time (timestampISO defaults to ~5 minutes before now for a near-live view; pass a historical timestamp for a past moment, e.g. the time of a badge event). A high-resolution capture of what the camera saw — people, vehicles, license plates, any detectable object — for object recognition, anomaly detection, incident investigation, or situational assessment. Optional crop args zoom into a sub-region.
15
+ - **get-settings** — current configuration of a camera or associated device (sensor, access controller): resolution, bitrate, image/exposure settings, storage. To CHANGE settings use **update-tool** instead.
16
+ - **get-media-uris** — the camera's streaming/playback URIs (LAN and WAN live-stream and VOD URLs, e.g. H.264 and M3U8). Use when the user needs direct stream or playback endpoints.
17
+ - **get-ai-thresholds** — the camera's AI detection threshold configuration (confidence thresholds for detection events). Use when diagnosing why a camera is or isn't generating AI events.
13
18
 
14
- What follows is a description of the behavior of this tool given the requestType "image"
15
-
16
- This tool should be used any time someone wants to specify a subset of cameras to use for a task, based on some features that the camera sees. For example, interior cameras, cameras facing the street, cameras with a view of X, Y, Z, etc.
17
-
18
- For instance if someone says "I want X using cameras with Y" then this tool should get a snapshot of the image to answer the question of if the camera satisfies the Y predicate.
19
-
20
- This tool captures and returns a real-time snapshot from a designated security camera.
21
- The image reflects the current scene in the camera's field of view and serves as a contextual
22
- input source for downstream tasks such as object recognition, anomaly detection, incident investigation,
23
- or situational assessment. When invoked, the tool provides the following:
24
- - Visual Scene Capture: A high-resolution image of what the camera is actively observing, including people, vehicles, license plates, and any detectable objects.
25
- - Optional zoom: pass cropX, cropY, cropWidth, cropHeight (each a percentage 0-100, origin at the top-left) to return only a sub-region of the frame so you can inspect a detail (e.g. a license plate or a doorway) more closely. Omit them for the full frame. When zooming into a small crop, pass a smaller downscaleFactor (e.g. 1-3) to preserve detail.
26
-
27
- What follows is a description of the behavior of this tool given the requestType "get-settings"
28
-
29
- This tool retrieves the current configuration for a specified camera or associated device (e.g., sensor, access controller). The returned JSON object can include detailed camera settings (e.g., resolution, bitrate) and various device-specific configurations (e.g. storage settings).
30
-
31
- NOTE: To update camera settings, use the update-tool instead.
32
-
33
- ---
34
-
35
- **AUTOMATIC SNAPSHOT FOR IMAGE QUALITY ISSUES** — When a user mentions camera image quality (darkness, brightness, blur, washed out, "doesn't look great", "fix the image", etc.), you MUST IMMEDIATELY:
36
- 1. Call camera-tool with requestType "image" to capture a snapshot WITHOUT asking first.
37
- 2. Analyze the image to identify quality issues.
38
- 3. Call camera-tool with requestType "get-settings" to check current camera settings.
39
- 4. Propose specific setting changes based on your analysis (store the exact values you plan to change, e.g. img_brightness, wdr_strength).
40
- 5. When the user confirms ("yes", "confirm", "fix it", "apply", "go ahead", "ok", etc.), call update-tool with those stored settings — see update-tool's description for the confirmation flow. NEVER skip the update-tool call.
41
-
42
- Examples that REQUIRE the automatic snapshot flow:
43
- - "This camera's image doesn't look great"
44
- - "The image quality is poor"
45
- - "Can you fix the image"
46
- - "Adjust settings to be optimal"
47
- - "The camera looks blurry/dark/washed out"
48
- - Any mention of image appearance problems.
49
-
50
- **VISUAL-FEATURE CAMERA FILTERING** — When the user asks for cameras filtered by what they can see (indoors/outdoors, "facing the street", "with a view of X", parking lot, entrance), you MUST:
51
- 1. First get the camera list via get-entity-tool or location-tool.
52
- 2. Then call camera-tool with requestType "image" for EACH candidate camera (in PARALLEL).
53
- 3. Analyze each image to determine if it meets the user's criteria.
54
- 4. Return only the cameras that match.
19
+ **This tool is REQUIRED, not optional, for two situations** (the exact steps are on the requestType parameter):
20
+ 1. **Camera image quality** — "doesn't look great", poor image quality, dark, bright, blurry, washed out, "fix the image", "adjust settings to be optimal", or any mention of image appearance problems: snapshot immediately without asking, then call get-workflows — a guided camera-tuning workflow exists for this; follow its playbook rather than improvising a fix.
21
+ 2. **Filtering cameras by what they see** interior vs exterior, "facing the street", "with a view of X", parking lot, entrance, or any "I want X using cameras with Y": snapshot each candidate camera and judge the predicate from the images.
55
22
  `;
56
23
  const logger = getLogger("camera-tool");
57
24
  const TOOL_ARGS = BASE_TOOL_ARGS;
25
+ // get-settings `detail: "core"` — the faceted config carries geometry/table
26
+ // sub-configs that dwarf the settings anyone reads or edits. Elide those
27
+ // (recursively, so facet nesting doesn't matter) and cap any remaining
28
+ // oversized leaf so the default response stays well under the 5k
29
+ // LLM-compaction threshold. `detail: "full"` skips this entirely.
30
+ const SETTINGS_BLOAT_KEYS = new Set([
31
+ "metering_config",
32
+ "motor_config",
33
+ "ptz_config",
34
+ "region_for_occupancy",
35
+ "region_of_interest",
36
+ "privacy_window_config",
37
+ "alert_regions",
38
+ ]);
39
+ const SETTINGS_VALUE_CHAR_LIMIT = 1_500;
40
+ const OMITTED_PLACEHOLDER = '<omitted at detail:"core" — pass detail:"full" to include>';
41
+ function compactSettings(value) {
42
+ if (Array.isArray(value)) {
43
+ const json = JSON.stringify(value);
44
+ if (json && json.length > SETTINGS_VALUE_CHAR_LIMIT)
45
+ return OMITTED_PLACEHOLDER;
46
+ return value.map(compactSettings);
47
+ }
48
+ if (typeof value === "object" && value !== null) {
49
+ const out = {};
50
+ for (const [key, child] of Object.entries(value)) {
51
+ if (SETTINGS_BLOAT_KEYS.has(key)) {
52
+ out[key] = OMITTED_PLACEHOLDER;
53
+ continue;
54
+ }
55
+ out[key] = compactSettings(child);
56
+ }
57
+ return out;
58
+ }
59
+ if (typeof value === "string" && value.length > SETTINGS_VALUE_CHAR_LIMIT) {
60
+ return OMITTED_PLACEHOLDER;
61
+ }
62
+ return value;
63
+ }
58
64
  const TOOL_HANDLER = async (args, extra) => {
59
65
  const { cameraUuid, timestampISO, requestType, cropX, cropY, cropWidth, cropHeight, downscaleFactor } = args;
60
66
  if (!cameraUuid) {
67
+ const ask = {
68
+ needUserInput: true,
69
+ commandForUser: "Which camera are you talking about?",
70
+ };
61
71
  return {
62
- content: [
63
- {
64
- type: "text",
65
- text: JSON.stringify({
66
- needUserInput: true,
67
- commandForUser: "Which camera are you talking about?",
68
- }),
69
- },
70
- ],
72
+ content: [{ type: "text", text: JSON.stringify(ask) }],
73
+ structuredContent: ask,
71
74
  };
72
75
  }
73
76
  // biome-ignore lint/suspicious/noExplicitAny: this will be returned, and can be any type since it will be JSON.stringify'd
@@ -86,66 +89,85 @@ const TOOL_HANDLER = async (args, extra) => {
86
89
  downscaleFactor: downscaleFactor ?? null,
87
90
  });
88
91
  if (!response.success || !response.imageData) {
92
+ // isError skips output validation, so the failure detail (status,
93
+ // message) reaches the model instead of being replaced by a -32602.
89
94
  return {
95
+ isError: true,
90
96
  content: [{ type: "text", text: JSON.stringify(response) }],
91
97
  };
92
98
  }
93
99
  logger.debug(`Received image response (base64 length ${response.imageData.length})`);
94
- return {
95
- content: [
96
- {
97
- type: "image",
98
- data: response.imageData,
99
- mimeType: "image/jpeg",
100
- },
101
- {
102
- type: "text",
103
- text: JSON.stringify({
104
- success: true,
105
- status: "image-attached",
106
- cameraUuid,
107
- timestampMs,
108
- cropApplied: response.crop ?? null,
109
- }),
110
- },
111
- ],
112
- };
113
- case "get-settings":
100
+ {
101
+ const imageStatus = {
102
+ success: true,
103
+ status: "image-attached",
104
+ cameraUuid,
105
+ timestampMs,
106
+ cropApplied: response.crop ?? null,
107
+ };
108
+ return {
109
+ content: [
110
+ {
111
+ type: "image",
112
+ data: response.imageData,
113
+ mimeType: "image/jpeg",
114
+ },
115
+ {
116
+ type: "text",
117
+ text: JSON.stringify(imageStatus),
118
+ },
119
+ ],
120
+ structuredContent: imageStatus,
121
+ };
122
+ }
123
+ case "get-settings": {
114
124
  response = await getCameraSettings(cameraUuid, requestModifiers, sessionId);
125
+ if (!response?.success) {
126
+ return {
127
+ isError: true,
128
+ content: [{ type: "text", text: JSON.stringify(response) }],
129
+ };
130
+ }
131
+ if (args.detail !== "full" && response?.config) {
132
+ response = { ...response, config: compactSettings(response.config) };
133
+ }
115
134
  return {
116
135
  content: [{ type: "text", text: JSON.stringify(response) }],
136
+ structuredContent: response,
117
137
  };
138
+ }
139
+ // Both throw on an api2 rejection (throwIfApiError), which the SDK turns
140
+ // into an isError result — so the success path is the only one here.
118
141
  case "get-media-uris":
119
142
  response = await getCameraMediaUris(cameraUuid, requestModifiers, sessionId);
120
143
  return {
121
144
  content: [{ type: "text", text: JSON.stringify(response) }],
145
+ structuredContent: response,
122
146
  };
123
147
  case "get-ai-thresholds":
124
148
  response = await getCameraAIThresholds(cameraUuid, requestModifiers, sessionId);
125
149
  return {
126
150
  content: [{ type: "text", text: JSON.stringify(response) }],
151
+ structuredContent: response,
127
152
  };
128
153
  default:
129
- response = {
130
- error: true,
131
- status: "missing unknown type from tool call",
154
+ return {
155
+ isError: true,
156
+ content: [
157
+ {
158
+ type: "text",
159
+ text: `Unknown requestType "${requestType}" — expected one of: image, get-settings, get-media-uris, get-ai-thresholds.`,
160
+ },
161
+ ],
132
162
  };
133
- break;
134
163
  }
135
- return {
136
- content: [
137
- {
138
- type: "text",
139
- text: JSON.stringify({ response }),
140
- },
141
- ],
142
- };
143
164
  };
144
165
  export function createTool(server) {
145
166
  server.registerTool(TOOL_NAME, {
146
167
  title: "Cameras",
147
168
  description: TOOL_DESCRIPTION,
148
169
  inputSchema: TOOL_ARGS,
170
+ outputSchema: OUTPUT_SCHEMA,
149
171
  annotations: { readOnlyHint: true },
150
172
  }, TOOL_HANDLER);
151
173
  }
@@ -1,43 +1,15 @@
1
1
  import { createToolStructuredContent, extractFromToolExtra } from "../util.js";
2
2
  import { ApiPayloadSchema, OUTPUT_SCHEMA, TOOL_ARGS, } from "../types/clips-tool-types.js";
3
- import { getSavedClips, getExpiringClips, getSharedLiveStreams, getTimelapseClips, getClipGroups, getSharedClipGroups, createClip, deleteClip, } from "../api/clips-tool-api.js";
3
+ import { getSavedClips, getExpiringClips, getSharedLiveStreams, getTimelapseClips, getClipGroups, getSharedClipGroups, createClip, deleteClip, updateClip, } from "../api/clips-tool-api.js";
4
4
  const TOOL_NAME = "clips-tool";
5
5
  const TOOL_DESCRIPTION = `
6
6
  Retrieves saved video clips from the Rhombus system. Saved clips can be viewed for up to 2 years and are typically found in the "Clips" tab of the "Saved Video" section of the Rhombus Console.
7
7
  Clips are either manually saved by the user, or automatically by some defined policy. Therefore, this tool
8
8
  is not for looking up the events that have occured.
9
9
 
10
- This tool allows you to:
11
- * Get saved clips or clips expiring soon (filter by devices, locations, search string, time range).
12
- * Get all shared live video streams for the organization.
13
- * Get all timelapse clips for the organization.
14
-
15
- Filter options (for saved and expiringSoon only):
16
- * Specific devices using their UUIDs (deviceUuidFilters).
17
- * Specific locations using their UUIDs (locationUuidFilters).
18
- * A simple string search on clip names (searchFilter).
19
- * A time range: start (timestampISOAfter) and/or end (timestampISOBefore) timestamp in ISO 8601 format.
20
-
21
- The tool returns a JSON object with the following structure and important fields:
22
- * **errorMsg (string | null):** An error message if the request failed.
23
- * **objecterror (boolean | null):** Indicates if an object-level error occurred.
24
- * **pageToken (string | null):** A token to be supplied on the next search request to get the next page of results. If this token is null, there is no more data available.
25
- * **savedClips (array of objects | null):** An array where each object represents a saved video clip. Each clip object contains the following important fields:
26
- * **uuid (string):** The unique identifier for the video clip.
27
- * **title (string):** The name given to the video clip.
28
- * **description (string | null):** An optional description for the clip.
29
- * **timestampMs (int64):** The start time of the video clip in milliseconds since epoch.
30
- * **createdAtMs (int64):** The creation timestamp of the clip in milliseconds since epoch.
31
- * **createdAtTimestamp (string):** The creation timestamp of the clip in ISO 8601 format.
32
- * **deviceUuid (string):** The UUID of the primary device (e.g., camera) that recorded the clip.
33
- * **deviceUuids (array of strings or null):** A list of UUIDs for all devices associated with the clip.
34
- * **durationSec (int32):** The length of the video clip in seconds.
35
- * **status (string):** The current processing status of the clip, with possible values such as INITIATING, UPLOADING, RENDERING, FAILED, COMPLETE, OFFLINE, or UNKNOWN.
36
- * **userUuid (string | null):** The UUID of the user associated with the clip, if applicable.
37
- * **sourceAlertUuid (string | null):** The UUID of the alert that triggered the creation of this clip, if any.
38
- * **sharedLiveVideoStreams (array):** When requestType is sharedLiveStreams, list of shared live video stream objects.
39
- * **timelapseClips (array):** When requestType is timelapseClips, list of timelapse clip objects.
10
+ Set "requestType" to: get saved clips or clips expiring soon (filter by devices, locations, name search, and time range); list the org's shared live video streams, timelapse clips, clip groups, or shared clip groups; **createClip** to save a new clip from a camera's footage (video evidence around an event); or **deleteClip** to permanently delete a saved clip — destructive and irreversible, confirm with the user first.
40
11
 
12
+ Each saved clip carries uuid, title, description, start and creation timestamps, the recording device(s), durationSec, processing status (INITIATING / UPLOADING / RENDERING / FAILED / COMPLETE / OFFLINE / UNKNOWN), and the alert that triggered it when there was one. Results paginate via pageToken (null when there is no more data).
41
13
  `;
42
14
  const TOOL_HANDLER = async (args, extra) => {
43
15
  const { requestModifiers, sessionId } = extractFromToolExtra(extra);
@@ -61,6 +33,24 @@ const TOOL_HANDLER = async (args, extra) => {
61
33
  }
62
34
  return createToolStructuredContent(await createClip(args.spliceRequest.cameraUuid, args.spliceRequest.startTimeMs, args.spliceRequest.endTimeMs, requestModifiers, sessionId));
63
35
  }
36
+ case "updateClip": {
37
+ if (!args.clipUuid) {
38
+ throw new Error("clipUuid is required for 'updateClip' requestType");
39
+ }
40
+ if (!args.clipTitle?.trim() && !args.clipDescription?.trim()) {
41
+ throw new Error("'updateClip' needs at least one of clipTitle or clipDescription — both were empty, so there is nothing to change");
42
+ }
43
+ const { existingTitle, ...result } = await updateClip(args.clipUuid, {
44
+ title: args.clipTitle?.trim() || undefined,
45
+ description: args.clipDescription?.trim() || undefined,
46
+ }, requestModifiers, sessionId);
47
+ return createToolStructuredContent({
48
+ ...result,
49
+ note: result.updateResult
50
+ ? `Renamed the clip${existingTitle ? ` "${existingTitle}"` : ""}${args.clipTitle?.trim() ? ` to "${args.clipTitle.trim()}"` : ""}. Fields you did not pass were left as they were.`
51
+ : undefined,
52
+ });
53
+ }
64
54
  case "deleteClip": {
65
55
  if (!args.clipUuid) {
66
56
  throw new Error("clipUuid is required for 'deleteClip' requestType");
@@ -12,17 +12,25 @@ export function createTool(server) {
12
12
  .array(z.string().describe("UUID string of an individual item"))
13
13
  .describe("An array of UUID strings representing the items to count. Each string should be a valid UUID."),
14
14
  },
15
+ // Every result from a tool with an outputSchema must carry
16
+ // structuredContent or isError: true — the SDK otherwise replaces the
17
+ // real message with an opaque -32602 (see CLAUDE.md).
18
+ outputSchema: {
19
+ count: z.number().optional().describe("How many UUIDs were provided"),
20
+ },
15
21
  annotations: { readOnlyHint: true },
16
22
  }, async ({ uuids }) => {
17
23
  try {
18
24
  logger.info("Counting UUIDs", uuids);
19
25
  return {
20
26
  content: [{ type: "text", text: `Count: ${uuids.length}` }],
27
+ structuredContent: { count: uuids.length },
21
28
  };
22
29
  }
23
30
  catch (e) {
24
31
  const errorMessage = e instanceof Error ? e.message : `Unknown error: ${e}`;
25
32
  return {
33
+ isError: true,
26
34
  content: [{ type: "text", text: `Error counting UUIDs: ${errorMessage}` }],
27
35
  };
28
36
  }