kaax-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tools.js ADDED
@@ -0,0 +1,926 @@
1
+ /**
2
+ * Tool registry — every callable function exposed to the LLM agent.
3
+ *
4
+ * Each entry pairs a JSON-Schema input definition (what the agent must send)
5
+ * with an async handler that returns text-form results suitable for the
6
+ * agent to reason over. Outputs are kept compact and **explicit about next
7
+ * steps** so the agent never has to guess what to do next.
8
+ */
9
+ import { z } from "zod";
10
+ import { issueOnboardingToken, pollOnboardingUntilBound, } from "./client.js";
11
+ import { aggregateDensity, findSimilar, } from "./spatial.js";
12
+ import { convertShapefileToKml } from "./convert.js";
13
+ import { downloadFile, openBrowser } from "./platform.js";
14
+ import { homedir } from "os";
15
+ import { join } from "path";
16
+ import { getLocale, t, tList } from "./i18n.js";
17
+ import { KaaxAPIError } from "./client.js";
18
+ // ─────────────────────────────────────────────────────────────────────────
19
+ // Helpers shared by every tool
20
+ // ─────────────────────────────────────────────────────────────────────────
21
+ function text(...parts) {
22
+ return { content: [{ type: "text", text: parts.join("\n") }] };
23
+ }
24
+ function errorText(err) {
25
+ const msg = err instanceof Error ? err.message : String(err);
26
+ return { content: [{ type: "text", text: `❌ ${msg}` }], isError: true };
27
+ }
28
+ /**
29
+ * Pretty-print a paid-tier refusal. Used both proactively (tier guard
30
+ * before the call) and reactively (when the server returns 403). The two
31
+ * paths must produce the SAME text so the agent doesn't see two different
32
+ * UX flows for the same condition.
33
+ */
34
+ function proRequiredText(upgradeUrl) {
35
+ const lines = [
36
+ `🔒 ${t("proRequired_title")}`,
37
+ "",
38
+ t("proRequired_body"),
39
+ "",
40
+ t("proRequired_cta", { url: upgradeUrl }),
41
+ "",
42
+ t("freeAvailable_header"),
43
+ ...tList("freeAvailable_items").map((s) => ` • ${s}`),
44
+ ];
45
+ return { content: [{ type: "text", text: lines.join("\n") }] };
46
+ }
47
+ /**
48
+ * Translate a thrown error into a localized, actionable tool result. Detects
49
+ * the canonical Kaax v2 error codes (carried as JSON in the body of a
50
+ * KaaxAPIError) and routes them through the dictionary. Falls back to the
51
+ * raw message for anything we don't recognise.
52
+ */
53
+ function apiErrorText(err, ctx) {
54
+ if (err instanceof KaaxAPIError) {
55
+ let parsed = null;
56
+ if (err.body) {
57
+ try {
58
+ parsed = JSON.parse(err.body);
59
+ }
60
+ catch {
61
+ /* not JSON — fall through */
62
+ }
63
+ }
64
+ const code = parsed?.error ?? "";
65
+ if (code === "API_ACCESS_DENIED" || err.status === 403) {
66
+ return proRequiredText(ctx?.upgradeUrl ??
67
+ "https://www.kaax-agritech.com/es/dashboard/payment");
68
+ }
69
+ if (code === "MISSING_API_KEY") {
70
+ return { content: [{ type: "text", text: `❌ ${t("missingApiKey")}` }], isError: true };
71
+ }
72
+ if (code === "INVALID_API_KEY") {
73
+ return { content: [{ type: "text", text: `❌ ${t("invalidApiKey")}` }], isError: true };
74
+ }
75
+ if (code === "RATE_LIMITED" || err.status === 429) {
76
+ return { content: [{ type: "text", text: `⌛ ${t("rateLimited")}` }] };
77
+ }
78
+ if (!err.status) {
79
+ return { content: [{ type: "text", text: `❌ ${t("network")}` }], isError: true };
80
+ }
81
+ }
82
+ return errorText(err);
83
+ }
84
+ /**
85
+ * Tier guard — runs BEFORE a paid call. Fetches the (cached) account
86
+ * overview, and if the user is on the Free tier we short-circuit with the
87
+ * Pro-required message instead of letting the server return 403. Saves a
88
+ * round-trip and keeps the UX consistent.
89
+ */
90
+ async function withPaidTier(client, fn) {
91
+ if (!client.hasApiKey()) {
92
+ return {
93
+ content: [{ type: "text", text: `❌ ${t("missingApiKey")}` }],
94
+ isError: true,
95
+ };
96
+ }
97
+ let overview;
98
+ try {
99
+ overview = await client.accountOverview();
100
+ }
101
+ catch (err) {
102
+ // If the overview call itself fails (network, invalid key, server down)
103
+ // surface that as-is. Don't pretend Pro is required when we don't know.
104
+ return apiErrorText(err);
105
+ }
106
+ if (!overview.user.active) {
107
+ return {
108
+ content: [{ type: "text", text: `⚠️ ${t("needsActivation")}` }],
109
+ };
110
+ }
111
+ if (!overview.limits.apiAccess) {
112
+ const locale = getLocale();
113
+ return proRequiredText(locale === "en" ? overview.upgradeUrlEn : overview.upgradeUrl);
114
+ }
115
+ try {
116
+ return await fn(overview);
117
+ }
118
+ catch (err) {
119
+ const locale = getLocale();
120
+ return apiErrorText(err, {
121
+ upgradeUrl: locale === "en" ? overview.upgradeUrlEn : overview.upgradeUrl,
122
+ });
123
+ }
124
+ }
125
+ function compactAnalysis(r) {
126
+ return {
127
+ id: r._id,
128
+ imageSetId: r.imageSetId,
129
+ name: r.name,
130
+ tags: r.tag,
131
+ analysisType: r.analysisType,
132
+ createdAt: r.createdAt,
133
+ metrics: {
134
+ areaHa: r.analysis?.totalAreaHa,
135
+ detections: r.analysis?.totalCounts,
136
+ survivalRate: r.analysis?.survivalRate,
137
+ depopulationRate: r.analysis?.depopulationRate,
138
+ replantingRate: r.analysis?.replantingRate,
139
+ pathRate: r.analysis?.pathRate,
140
+ },
141
+ };
142
+ }
143
+ // ─────────────────────────────────────────────────────────────────────────
144
+ // Schemas
145
+ // ─────────────────────────────────────────────────────────────────────────
146
+ const AnalysisTypeSchema = z.enum(["replanting", "counting", "path"]);
147
+ const ListAnalysesArgs = z.object({
148
+ name: z.string().optional().describe("Filter by field name (substring match server-side)"),
149
+ start: z.union([z.string(), z.number()]).optional().describe("Start date — ISO 8601 string or unix millis"),
150
+ end: z.union([z.string(), z.number()]).optional().describe("End date — ISO 8601 string or unix millis"),
151
+ tag: z.string().optional().describe("Filter by a single tag"),
152
+ type: AnalysisTypeSchema.optional().describe("Analysis type: replanting / counting / path"),
153
+ page: z.number().int().min(1).optional().describe("Page number (default: 1)"),
154
+ limit: z.number().int().min(1).max(100).optional().describe("Page size (default: 20, max: 100)"),
155
+ });
156
+ const GetAnalysisArgs = z.object({
157
+ id: z.string().describe("The analysis _id to inspect"),
158
+ });
159
+ const SuggestTagsArgs = z.object({
160
+ fieldName: z.string().describe("Field/parcel name as the user calls it"),
161
+ crop: z.string().optional().describe("Crop type if known (sugarcane, pineapple…)"),
162
+ region: z.string().optional().describe("Region / country / farm name"),
163
+ });
164
+ const SuggestConfigArgs = z.object({
165
+ analysisType: AnalysisTypeSchema.describe("Which analysis you're configuring"),
166
+ crop: z.string().optional().describe("Crop type — affects defaults"),
167
+ goal: z
168
+ .enum(["balanced", "high_recall", "high_precision"])
169
+ .optional()
170
+ .describe("What you optimise for. Default: balanced."),
171
+ });
172
+ const ExplainParameterArgs = z.object({
173
+ parameter: z.enum([
174
+ "blockSize",
175
+ "accuracy",
176
+ "iou",
177
+ "radiusFactor",
178
+ "overlap",
179
+ "smallMax",
180
+ "mediumMax",
181
+ "largeMax",
182
+ "seedsPerMeter",
183
+ "seedsPerBox",
184
+ ]),
185
+ });
186
+ const FindSimilarArgs = z.object({
187
+ analysisId: z.string().describe("Reference analysis _id"),
188
+ topK: z.number().int().min(1).max(20).optional().describe("How many neighbours to return (default 5)"),
189
+ });
190
+ const DensityStatsArgs = z.object({
191
+ tag: z.string().optional(),
192
+ type: AnalysisTypeSchema.optional(),
193
+ start: z.union([z.string(), z.number()]).optional(),
194
+ end: z.union([z.string(), z.number()]).optional(),
195
+ });
196
+ const ConvertShpArgs = z.object({
197
+ shpPath: z.string().describe("Absolute path to the .shp file on the user's machine"),
198
+ outputPath: z.string().optional().describe("Optional .kml output path. Defaults next to the .shp file."),
199
+ });
200
+ const PeerSuggestionsArgs = z.object({
201
+ type: AnalysisTypeSchema.describe("Analysis type whose peer average to fetch"),
202
+ });
203
+ const AttachTagsArgs = z.object({
204
+ fieldId: z.string().describe("Field (imageSet) id to tag"),
205
+ tags: z
206
+ .array(z.string().min(1).max(50))
207
+ .min(1)
208
+ .max(30)
209
+ .describe("Tags to attach. Server normalises them."),
210
+ });
211
+ const StartOnboardingArgs = z.object({});
212
+ const CompleteOnboardingArgs = z.object({
213
+ token: z.string().min(16).max(64).describe("The onboarding token returned by kaax_start_onboarding"),
214
+ maxWaitMinutes: z
215
+ .number()
216
+ .int()
217
+ .min(1)
218
+ .max(15)
219
+ .optional()
220
+ .describe("How long to keep polling. Default 10 minutes. Hard cap 15."),
221
+ });
222
+ const RequestDownloadArgs = z.object({
223
+ useCase: z
224
+ .string()
225
+ .max(500)
226
+ .optional()
227
+ .describe("Short description of what the user wants to do with Kaax Labeling / Desktop"),
228
+ company: z.string().max(120).optional(),
229
+ locale: z.enum(["en", "es", "zh"]).optional(),
230
+ });
231
+ const DownloadKaaxArgs = z.object({
232
+ product: z
233
+ .enum(["kaaxLabeling", "kaaxDesktop"])
234
+ .describe("Which binary to fetch"),
235
+ outputDir: z
236
+ .string()
237
+ .optional()
238
+ .describe("Directory to save the binary. Defaults to the user's home Downloads folder."),
239
+ });
240
+ export function buildTools() {
241
+ return [
242
+ // ── Onboarding (browser handoff) ────────────────────────────────────
243
+ {
244
+ name: "kaax_start_onboarding",
245
+ description: "Start a brand-new Kaax account. Issues a single-use 15-minute token and opens the user's default browser at the Kaax signup page with the token attached. The user fills the form (email/password) in the browser; once they submit, the page binds their new account to the token so kaax_complete_onboarding can pick up the apiKey. Use when the agent has no KAAX_API_KEY configured and the user wants to onboard from scratch.",
246
+ inputSchema: zodToJsonSchema(StartOnboardingArgs),
247
+ async handler(_args, { client }) {
248
+ try {
249
+ const issued = await issueOnboardingToken(client.baseUrl, "mcp");
250
+ const signupUrl = `${client.baseUrl}${issued.signupUrlHint}`;
251
+ const opened = openBrowser(signupUrl);
252
+ return text("🚀 Onboarding started.", "", opened.spawned
253
+ ? `Opened your default browser at: ${signupUrl}`
254
+ : `Could not open the browser automatically (platform=${opened.platform}). Open this URL manually: ${signupUrl}`, "", `Token: ${issued.token}`, `Expires in: ${Math.floor(issued.ttlSeconds / 60)} minutes`, "", "Next step: complete the signup form in the browser. Then call kaax_complete_onboarding with the token above to fetch your apiKey.");
255
+ }
256
+ catch (err) {
257
+ return errorText(err);
258
+ }
259
+ },
260
+ },
261
+ {
262
+ name: "kaax_complete_onboarding",
263
+ description: "Polls the Kaax server until the onboarding token has been bound to a freshly-created user (i.e. they finished the signup form in the browser). Returns the apiKey **once** — set it via KAAX_API_KEY on next MCP run, or use it immediately within this session. Times out after 10 minutes by default.",
264
+ inputSchema: zodToJsonSchema(CompleteOnboardingArgs),
265
+ async handler(args, { client }) {
266
+ try {
267
+ const { token, maxWaitMinutes } = CompleteOnboardingArgs.parse(args);
268
+ const result = await pollOnboardingUntilBound(token, {
269
+ baseUrl: client.baseUrl,
270
+ maxWaitMs: (maxWaitMinutes ?? 10) * 60_000,
271
+ });
272
+ if (result.status === "bound") {
273
+ // Late-bind the apiKey on the in-process client so subsequent
274
+ // tool calls in this session don't need a restart.
275
+ client.setApiKey(result.apiKey);
276
+ return text("✅ Account ready. ApiKey acquired and configured for this session.", "", `apiKey: ${result.apiKey}`, "", "⚠️ This key is shown ONLY ONCE. Save it permanently by setting the `KAAX_API_KEY` environment variable in your MCP client config — otherwise you'll need to retrieve it from /dashboard/api-manage next time.", "", "Next: activate your account via the email we sent before requesting downloads or running paid analyses.");
277
+ }
278
+ if (result.status === "pending") {
279
+ return text("⌛ Still pending after the timeout. Re-run kaax_complete_onboarding with the same token to keep waiting (token is valid for 15 minutes total).");
280
+ }
281
+ if (result.status === "expired") {
282
+ return text("⏰ Token expired. Call kaax_start_onboarding again to issue a fresh one.");
283
+ }
284
+ // already_consumed
285
+ return text("🔒 This token was already consumed by another caller. Issue a new one with kaax_start_onboarding.");
286
+ }
287
+ catch (err) {
288
+ return errorText(err);
289
+ }
290
+ },
291
+ },
292
+ // ── License & billing (free-tier friendly) ──────────────────────────
293
+ {
294
+ name: "kaax_check_license",
295
+ description: "Show the active plan, what's enabled for the current account (API access, fields/models/configs limits, download permission) and the upgrade URL. ALWAYS call this BEFORE any analysis/field/model/tag tool so you can warn the user upfront if they're on the Free tier — analyses require a Pro subscription.",
296
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
297
+ async handler(_args, { client }) {
298
+ try {
299
+ if (!client.hasApiKey()) {
300
+ return {
301
+ content: [{ type: "text", text: `ℹ️ ${t("missingApiKey")}` }],
302
+ };
303
+ }
304
+ const o = await client.accountOverview(true /* force refresh */);
305
+ const locale = getLocale();
306
+ const upgrade = locale === "en" ? o.upgradeUrlEn : o.upgradeUrl;
307
+ const lines = [];
308
+ lines.push(locale === "en"
309
+ ? `📋 Account: ${o.user.email}`
310
+ : `📋 Cuenta: ${o.user.email}`);
311
+ lines.push(locale === "en"
312
+ ? ` Plan: ${o.plan.name}${o.plan.isPaid ? "" : " (Free)"}`
313
+ : ` Plan: ${o.plan.name}${o.plan.isPaid ? "" : " (Gratis)"}`);
314
+ lines.push(locale === "en"
315
+ ? ` Activated: ${o.user.active ? "yes" : "no — check your email"}`
316
+ : ` Activado: ${o.user.active ? "sí" : "no — revisá tu correo"}`);
317
+ lines.push("");
318
+ lines.push(locale === "en" ? "Enabled in this plan:" : "Disponible en este plan:");
319
+ const ok = "✅";
320
+ const no = "❌";
321
+ lines.push(` ${o.capabilities.onboarding ? ok : no} ${locale === "en" ? "Onboarding" : "Onboarding"}`);
322
+ lines.push(` ${o.capabilities.localUtilities ? ok : no} ${locale === "en"
323
+ ? "Local utilities (shapefile → KML, tag/config suggestions)"
324
+ : "Utilidades locales (shapefile → KML, sugerencias de tags/config)"}`);
325
+ lines.push(` ${o.capabilities.downloadRequest ? ok : no} ${locale === "en" ? "Desktop app download" : "Descarga de apps de escritorio"}${o.download.permission ? (locale === "en" ? " (approved)" : " (aprobado)") : ""}`);
326
+ lines.push(` ${o.capabilities.analysesRead ? ok : no} ${locale === "en" ? "Analyses & fields API (Pro)" : "API de análisis y campos (Pro)"}`);
327
+ lines.push("");
328
+ lines.push(locale === "en" ? "Limits:" : "Límites:");
329
+ lines.push(` ${locale === "en" ? "Fields" : "Campos"}: ${o.limits.fields}`);
330
+ lines.push(` ${locale === "en" ? "Local models" : "Modelos locales"}: ${o.limits.localModels}`);
331
+ lines.push(` ${locale === "en" ? "Custom configurations" : "Configuraciones personalizadas"}: ${o.limits.customConfigurations}`);
332
+ if (!o.plan.isPaid) {
333
+ lines.push("");
334
+ lines.push(locale === "en"
335
+ ? `💎 Upgrade to Pro: ${upgrade}`
336
+ : `💎 Upgrade a Pro: ${upgrade}`);
337
+ lines.push(locale === "en"
338
+ ? "Call `kaax_open_billing` to open it in your browser."
339
+ : "Llamá a `kaax_open_billing` para abrirlo en el navegador.");
340
+ }
341
+ return { content: [{ type: "text", text: lines.join("\n") }] };
342
+ }
343
+ catch (err) {
344
+ return apiErrorText(err);
345
+ }
346
+ },
347
+ },
348
+ {
349
+ name: "kaax_open_billing",
350
+ description: "Open the Kaax billing / plans page in the user's default browser. Use after kaax_check_license shows Free tier and the user wants to upgrade. Honors KAAX_LOCALE for the URL.",
351
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
352
+ async handler(_args, { client }) {
353
+ try {
354
+ const locale = getLocale();
355
+ let url;
356
+ if (client.hasApiKey()) {
357
+ try {
358
+ const o = await client.accountOverview();
359
+ url = locale === "en" ? o.upgradeUrlEn : o.upgradeUrl;
360
+ }
361
+ catch {
362
+ url = `${client.baseUrl}/${locale}/dashboard/payment`;
363
+ }
364
+ }
365
+ else {
366
+ url = `${client.baseUrl}/${locale}/dashboard/payment`;
367
+ }
368
+ const opened = openBrowser(url);
369
+ return {
370
+ content: [
371
+ {
372
+ type: "text",
373
+ text: opened.spawned
374
+ ? locale === "en"
375
+ ? `🌐 Opened the billing page: ${url}`
376
+ : `🌐 Abrí la página de planes: ${url}`
377
+ : locale === "en"
378
+ ? `Could not open the browser (platform=${opened.platform}). Open this URL manually: ${url}`
379
+ : `No pude abrir el navegador (plataforma=${opened.platform}). Abrí manualmente: ${url}`,
380
+ },
381
+ ],
382
+ };
383
+ }
384
+ catch (err) {
385
+ return errorText(err);
386
+ }
387
+ },
388
+ },
389
+ // ── Querying the API ─────────────────────────────────────────────────
390
+ {
391
+ name: "kaax_list_analyses",
392
+ description: "[Pro] List your Kaax analyses. Supports filtering by name, date range, tag and analysis type, plus pagination. REQUIRES a Pro subscription — the tool returns an upgrade message for Free users. Use this whenever the user asks about their fields, recent runs, density, survival, replanting rates, etc.",
393
+ inputSchema: zodToJsonSchema(ListAnalysesArgs),
394
+ async handler(args, { client }) {
395
+ return withPaidTier(client, async () => {
396
+ const filters = ListAnalysesArgs.parse(args);
397
+ const page = await client.listAnalyses(filters);
398
+ if (page.data.length === 0) {
399
+ return text("No analyses match those filters.", "", "If the user just signed up, suggest they upload their first field and watch the official tutorial:", "• kaax://docs/quickstart", "• kaax://docs/tutorial");
400
+ }
401
+ const compact = page.data.map(compactAnalysis);
402
+ return text(`Found ${page.total} analyses · returning ${page.data.length} on page ${page.page} (limit ${page.limit}).`, "", JSON.stringify(compact, null, 2));
403
+ });
404
+ },
405
+ },
406
+ {
407
+ name: "kaax_get_analysis",
408
+ description: "[Pro] Get a single analysis in full detail by its _id. REQUIRES a Pro subscription. Returns every metric plus the report URLs. Use after kaax_list_analyses when the user wants a deeper look.",
409
+ inputSchema: zodToJsonSchema(GetAnalysisArgs),
410
+ async handler(args, { client }) {
411
+ return withPaidTier(client, async () => {
412
+ const { id } = GetAnalysisArgs.parse(args);
413
+ const page = await client.listAnalyses({ limit: 100 });
414
+ const match = page.data.find((r) => r._id === id);
415
+ if (!match) {
416
+ return text(`No analysis with _id ${id} in the first page. Try a tighter filter (date / tag / name) and re-run kaax_list_analyses.`);
417
+ }
418
+ return text(JSON.stringify(match, null, 2));
419
+ });
420
+ },
421
+ },
422
+ // ── Setup & guidance ────────────────────────────────────────────────
423
+ {
424
+ name: "kaax_check_setup_status",
425
+ description: "[Pro] Reports what the user has in their Kaax account (fields, models, custom configs, analyses by type, tags) and what they're missing. REQUIRES a Pro subscription. Call this **first** when the user says they're new or things 'aren't working' AFTER you've confirmed they're on Pro via kaax_check_license.",
426
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
427
+ async handler(_args, { client }) {
428
+ return withPaidTier(client, async () => {
429
+ // Preferred path — v2 endpoint already computes everything server-side
430
+ // (cheaper, accurate, never leaks other users' data).
431
+ const v2 = await client.setupStatus();
432
+ if (v2) {
433
+ const lines = [];
434
+ lines.push("📊 Account snapshot");
435
+ lines.push(` • Fields: ${v2.counts.fields}`);
436
+ lines.push(` • Detection models: ${v2.counts.models}`);
437
+ lines.push(` • Custom configurations: ${v2.counts.customConfigurations}`);
438
+ lines.push(` • Tags: ${v2.counts.tags}`);
439
+ lines.push(` • Analyses run: ${v2.counts.analyses}`);
440
+ const types = Object.entries(v2.analysesByType);
441
+ if (types.length) {
442
+ lines.push(" • Fields by analysis type:");
443
+ for (const [t, n] of types)
444
+ lines.push(` ${t}: ${n}`);
445
+ }
446
+ if (v2.topTags.length) {
447
+ lines.push(" • Top tags: " +
448
+ v2.topTags
449
+ .slice(0, 10)
450
+ .map((t) => `${t.tag} (${t.count})`)
451
+ .join(", "));
452
+ }
453
+ if (v2.nextSteps.length) {
454
+ lines.push("");
455
+ lines.push("🧭 Next-step suggestions:");
456
+ for (const s of v2.nextSteps) {
457
+ const sev = s.severity === "critical" ? "🔴" : s.severity === "warning" ? "🟡" : "ℹ️";
458
+ lines.push(` ${sev} ${s.title}`);
459
+ if (s.description)
460
+ lines.push(` ${s.description}`);
461
+ if (s.resource)
462
+ lines.push(` Resource: ${s.resource}`);
463
+ if (s.href)
464
+ lines.push(` Link: ${s.href}`);
465
+ }
466
+ }
467
+ return text(...lines);
468
+ }
469
+ // Fallback — v0 aggregation, less accurate but always works.
470
+ const all = await client.listAllAnalyses({}, { pageSize: 100, maxPages: 5 });
471
+ const byType = groupBy(all.data, (r) => r.analysisType ?? "unknown");
472
+ const fields = new Set(all.data.map((r) => r.imageSetId)).size;
473
+ const tags = uniqueTags(all.data);
474
+ const lines = [];
475
+ lines.push(`📊 Account snapshot (sampled ${all.data.length} most-recent analyses)`);
476
+ lines.push(` • Unique fields (imageSets): ${fields}`);
477
+ lines.push(` • Tags in use: ${tags.length}${tags.length ? " — " + tags.slice(0, 10).join(", ") : ""}`);
478
+ lines.push(` • By analysis type:`);
479
+ for (const t of ["replanting", "counting", "path", "unknown"]) {
480
+ lines.push(` ${t}: ${(byType[t] ?? []).length}`);
481
+ }
482
+ lines.push("");
483
+ lines.push("🧭 Next-step suggestions:");
484
+ if (all.data.length === 0) {
485
+ lines.push(" • Zero analyses — point the user to the 30-min tutorial.");
486
+ lines.push(" • Resource: kaax://docs/quickstart");
487
+ }
488
+ if (fields > 0 && tags.length === 0) {
489
+ lines.push(" • Fields without tags. Suggest a tag taxonomy with kaax_suggest_tags_for_field.");
490
+ }
491
+ if ((byType.replanting?.length ?? 0) === 0 && (byType.counting?.length ?? 0) === 0) {
492
+ lines.push(" • No replanting/counting runs. The user probably needs to upload a model first — kaax://docs/models.");
493
+ }
494
+ if (tags.length > 0 && tags.length < 3) {
495
+ lines.push(" • Few tags — encourage organising by season + region + crop. See kaax://docs/tags.");
496
+ }
497
+ return text(...lines);
498
+ });
499
+ },
500
+ },
501
+ // ── Tags & organisation ────────────────────────────────────────────
502
+ {
503
+ name: "kaax_list_tags",
504
+ description: "[Pro] Returns every tag currently in use across the user's analyses, with usage counts. REQUIRES a Pro subscription. Useful to understand how their data is already organised before suggesting more tags.",
505
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
506
+ async handler(_args, { client }) {
507
+ return withPaidTier(client, async () => {
508
+ const all = await client.listAllAnalyses({}, { pageSize: 100, maxPages: 5 });
509
+ const counts = new Map();
510
+ for (const r of all.data) {
511
+ for (const tagValue of r.tag ?? [])
512
+ counts.set(tagValue, (counts.get(tagValue) ?? 0) + 1);
513
+ }
514
+ if (counts.size === 0) {
515
+ return text("No tags found across the recent analyses.", "", "Suggest creating a basic taxonomy:", " • season — e.g. zafra-2024", " • region — e.g. escuintla", " • crop — e.g. cana, pina", "", "Call kaax_suggest_tags_for_field with the user's field name for tailored suggestions.");
516
+ }
517
+ const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1]);
518
+ return text("Tags in use (descending frequency):", ...sorted.map(([tagValue, n]) => ` • ${tagValue} — ${n}`), "", "If two tags carry the same meaning, suggest the user merge them in /dashboard/manage.");
519
+ });
520
+ },
521
+ },
522
+ {
523
+ name: "kaax_suggest_tags_for_field",
524
+ description: "[Free] Given a field name, crop and/or region, returns a concise list of recommended tags following Kaax's best practices (season + region + crop + lifecycle). Pure heuristic, no API call — works on the Free tier.",
525
+ inputSchema: zodToJsonSchema(SuggestTagsArgs),
526
+ async handler(args) {
527
+ try {
528
+ const { fieldName, crop, region } = SuggestTagsArgs.parse(args);
529
+ const year = new Date().getFullYear();
530
+ const slug = (s) => s
531
+ .toLowerCase()
532
+ .normalize("NFD")
533
+ .replace(/[̀-ͯ]/g, "")
534
+ .replace(/[^a-z0-9]+/g, "-")
535
+ .replace(/^-|-$/g, "");
536
+ const suggestions = [
537
+ `zafra-${year}`,
538
+ crop ? `cultivo-${slug(crop)}` : "cultivo-cana",
539
+ region ? `region-${slug(region)}` : undefined,
540
+ `parcela-${slug(fieldName)}`,
541
+ "estado-activo",
542
+ ].filter(Boolean);
543
+ return text("Suggested tags for this field (apply 2–4 of these):", ...suggestions.map((t) => ` • ${t}`), "", "Naming rules:", " • lowercase, no accents, hyphenated", " • prefix by axis (zafra-, region-, cultivo-, estado-)", " • combine tags in filters to slice across axes", "", "Apply them at /dashboard/manage → Etiquetas. See also kaax://docs/tags.");
544
+ }
545
+ catch (err) {
546
+ return errorText(err);
547
+ }
548
+ },
549
+ },
550
+ // ── Configuration suggestions ──────────────────────────────────────
551
+ {
552
+ name: "kaax_suggest_configuration",
553
+ description: "[Free] Returns a recommended advanced-configuration preset for a given analysis type. Pure heuristic, no API call — works on the Free tier. The preset reflects the defaults that most Kaax users converge on after iteration. Use it as a starting point — never as final values.",
554
+ inputSchema: zodToJsonSchema(SuggestConfigArgs),
555
+ async handler(args) {
556
+ try {
557
+ const { analysisType, crop, goal = "balanced" } = SuggestConfigArgs.parse(args);
558
+ const presets = {
559
+ counting: {
560
+ blockSize: 2048,
561
+ accuracy: 0.25,
562
+ postProcessing: {
563
+ refinement: true,
564
+ radiusFactor: 0.8,
565
+ iou: 0.2,
566
+ },
567
+ sizeClassification: {
568
+ smallMax: 0.05,
569
+ mediumMax: 0.15,
570
+ largeMax: 0.3,
571
+ },
572
+ },
573
+ replanting: {
574
+ blockSize: 2048,
575
+ accuracy: 0.25,
576
+ lineThickness: 1.5,
577
+ seedsPerMeter: 3.75,
578
+ seedsPerBox: 30,
579
+ postProcessing: { refinement: true, iou: 0.2 },
580
+ },
581
+ path: {
582
+ blockSize: 2048,
583
+ accuracy: 0.3,
584
+ overlap: 0.1,
585
+ },
586
+ };
587
+ const config = JSON.parse(JSON.stringify(presets[analysisType]));
588
+ // Goal tweaks — affect recall/precision trade-off knobs.
589
+ if (goal === "high_recall") {
590
+ config.accuracy = 0.18;
591
+ if (config.postProcessing)
592
+ config.postProcessing.iou = 0.15;
593
+ }
594
+ else if (goal === "high_precision") {
595
+ config.accuracy = 0.35;
596
+ if (config.postProcessing)
597
+ config.postProcessing.iou = 0.3;
598
+ }
599
+ return text(`Recommended ${analysisType} configuration (goal: ${goal}${crop ? `, crop: ${crop}` : ""}):`, "", JSON.stringify(config, null, 2), "", "Apply at /dashboard/manage → Configuración Avanzada.", "If results undershoot, increase recall (lower accuracy + lower IoU). Overshoot → opposite.", "Use kaax_explain_advanced_config for any parameter you're not sure about.");
600
+ }
601
+ catch (err) {
602
+ return errorText(err);
603
+ }
604
+ },
605
+ },
606
+ {
607
+ name: "kaax_explain_advanced_config",
608
+ description: "[Free] Plain-language explanation of one advanced-configuration parameter. Static docs, no API call — works on the Free tier.",
609
+ inputSchema: zodToJsonSchema(ExplainParameterArgs),
610
+ async handler(args) {
611
+ try {
612
+ const { parameter } = ExplainParameterArgs.parse(args);
613
+ const guide = {
614
+ blockSize: "Side length (px) of the tile fed to the model. Larger = more context but slower and more memory. Default 2048 works for drone orthomosaics; raise to 4096 only on very high-res inputs.",
615
+ accuracy: "Confidence threshold at which a detection is kept. Lower = more recall (fewer misses, more false positives). Higher = more precision. Safe range 0.18–0.4; default 0.25.",
616
+ iou: "Intersection-over-Union threshold for deduplication. Two detections overlapping above IoU are merged. Lower = remove more duplicates (risk under-counting). Higher = keep more (risk double-counting). Default 0.2.",
617
+ radiusFactor: "Radius multiplier (× plant size) used to search for duplicate neighbours. Smaller misses overlaps; larger over-merges. Default 0.8.",
618
+ overlap: "Fractional overlap between processing tiles. Higher overlap reduces seam artefacts on object boundaries but costs more compute. Default 0.1.",
619
+ smallMax: "Upper bound (m²) for the 'very small' size bucket in counting size-classification. Anything ≤ this is counted as very small.",
620
+ mediumMax: "Upper bound (m²) for the 'medium' size bucket. Adjust together with smallMax/largeMax so buckets cover the expected plant-area distribution.",
621
+ largeMax: "Upper bound (m²) for the 'large' size bucket. Anything above is unclassified.",
622
+ seedsPerMeter: "Replanting calculation: how many seeds are sown per linear meter of furrow. Defaults to your crop's agronomic guide.",
623
+ seedsPerBox: "Replanting calculation: seeds per packaging unit. Used to translate seed counts back into purchase orders.",
624
+ };
625
+ const note = guide[parameter] || "No description available.";
626
+ return text(`Parameter: ${parameter}`, "", note);
627
+ }
628
+ catch (err) {
629
+ return errorText(err);
630
+ }
631
+ },
632
+ },
633
+ // ── Spatial intelligence ───────────────────────────────────────────
634
+ {
635
+ name: "kaax_find_similar_fields",
636
+ description: "[Pro] Given an analysis _id, returns the most similar analyses from the user's history. REQUIRES a Pro subscription. Uses metric cosine similarity, weighted by geographic proximity when KML centroids are available. Surface this when the user asks 'which of my fields look like this one' or 'where else have I seen this pattern'.",
637
+ inputSchema: zodToJsonSchema(FindSimilarArgs),
638
+ async handler(args, { client }) {
639
+ return withPaidTier(client, async () => {
640
+ const { analysisId, topK = 5 } = FindSimilarArgs.parse(args);
641
+ const all = await client.listAllAnalyses({}, { pageSize: 100, maxPages: 5 });
642
+ const ref = all.data.find((r) => r._id === analysisId);
643
+ if (!ref) {
644
+ return text(`Analysis ${analysisId} not found in the most recent 500 records. Ask the user to widen the search or filter by tag.`);
645
+ }
646
+ const neighbours = findSimilar(ref, all.data, topK);
647
+ if (neighbours.length === 0) {
648
+ return text("No similar analyses found — pool is too small.");
649
+ }
650
+ return text(`Reference: ${ref.name ?? ref._id} (${ref.analysisType})`, "", "Top similar analyses:", ...neighbours.map((n, i) => ` ${i + 1}. ${n.record.name ?? n.record._id} · score ${n.score.toFixed(3)}${n.distanceKm !== undefined ? ` · ${n.distanceKm.toFixed(2)} km` : ""}`), "", "Use kaax_get_analysis with any of these IDs to dig into the metrics.");
651
+ });
652
+ },
653
+ },
654
+ {
655
+ name: "kaax_get_density_stats",
656
+ description: "[Pro] Aggregates density and rate stats across the user's analyses. REQUIRES a Pro subscription. Returns total area, total detections, detections/ha, and average survival/depopulation/replanting rates.",
657
+ inputSchema: zodToJsonSchema(DensityStatsArgs),
658
+ async handler(args, { client }) {
659
+ return withPaidTier(client, async () => {
660
+ const filters = DensityStatsArgs.parse(args);
661
+ const all = await client.listAllAnalyses(filters, { pageSize: 100, maxPages: 10 });
662
+ const stats = aggregateDensity(all.data);
663
+ return text(`Density stats across ${stats.count} analyses${filters.tag ? ` · tag=${filters.tag}` : ""}${filters.type ? ` · type=${filters.type}` : ""}`, "", JSON.stringify(stats, null, 2));
664
+ });
665
+ },
666
+ },
667
+ // ── v2 fields / tags / models ─────────────────────────────────────
668
+ {
669
+ name: "kaax_list_fields",
670
+ description: "[Pro] List the user's fields (imageSets) with their tags, analysis type and whether they already have geometry attached. REQUIRES a Pro subscription. Use this when the user asks 'which fields do I have' — it's cheaper than listing analyses because it skips report data.",
671
+ inputSchema: zodToJsonSchema(z.object({
672
+ tag: z.string().optional(),
673
+ type: AnalysisTypeSchema.optional(),
674
+ q: z.string().optional().describe("Substring match on field name"),
675
+ page: z.number().int().min(1).optional(),
676
+ limit: z.number().int().min(1).max(100).optional(),
677
+ })),
678
+ async handler(args, { client }) {
679
+ return withPaidTier(client, async () => {
680
+ const parsed = z
681
+ .object({
682
+ tag: z.string().optional(),
683
+ type: AnalysisTypeSchema.optional(),
684
+ q: z.string().optional(),
685
+ page: z.number().int().min(1).optional(),
686
+ limit: z.number().int().min(1).max(100).optional(),
687
+ })
688
+ .parse(args);
689
+ const page = await client.listFields(parsed);
690
+ if (!page) {
691
+ return text("The v2 /fields endpoint is not available on this Kaax deployment. Fall back to kaax_list_analyses.");
692
+ }
693
+ if (page.data.length === 0) {
694
+ return text("No fields match those filters. If the user has not uploaded any yet, point them at kaax://docs/quickstart.");
695
+ }
696
+ return text(`Found ${page.total} fields · returning ${page.data.length} on page ${page.page}.`, "", JSON.stringify(page.data, null, 2));
697
+ });
698
+ },
699
+ },
700
+ {
701
+ name: "kaax_list_models",
702
+ description: "[Pro] Returns every detection model the user has uploaded. REQUIRES a Pro subscription. Models are required for replanting & counting analyses — call this whenever the user reports 'I can't analyse' or 'why is the run failing'.",
703
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
704
+ async handler(_args, { client }) {
705
+ return withPaidTier(client, async () => {
706
+ const models = await client.listModels();
707
+ if (!models) {
708
+ return text("The v2 /models endpoint is not available on this Kaax deployment. Ask the user to check their /dashboard/models page directly.");
709
+ }
710
+ if (models.total === 0) {
711
+ return text("No detection models uploaded yet — counting & replanting analyses will fail until at least one is present.", "", "Send the user to the tutorial: kaax://docs/models");
712
+ }
713
+ return text(`Detection models (${models.total}):`, "", ...models.data.map((m) => ` • ${m.name} · type=${m.type ?? "unknown"} · id=${m.id}`));
714
+ });
715
+ },
716
+ },
717
+ {
718
+ name: "kaax_attach_field_tags",
719
+ description: "[Pro] Attach one or more tags to a field. REQUIRES a Pro subscription. Server normalises (lowercase, deburred, hyphenated) and dedups. Use after kaax_suggest_tags_for_field to actually persist the suggestion.",
720
+ inputSchema: zodToJsonSchema(AttachTagsArgs),
721
+ async handler(args, { client }) {
722
+ return withPaidTier(client, async () => {
723
+ const { fieldId, tags } = AttachTagsArgs.parse(args);
724
+ const result = await client.attachFieldTags(fieldId, tags);
725
+ if (!result) {
726
+ return text("Write endpoint not available on this Kaax deployment. The user can apply tags manually at /dashboard/manage → Etiquetas.");
727
+ }
728
+ return text(`✅ Field ${result.fieldId} now has ${result.tags.length} tag(s):`, " " + result.tags.join(", "));
729
+ });
730
+ },
731
+ },
732
+ {
733
+ name: "kaax_peer_config_suggestions",
734
+ description: "[Pro] Returns an anonymised average of advanced-configuration parameters used by other Kaax users for the requested analysis type. REQUIRES a Pro subscription. Privacy-preserving (excludes the caller, requires a minimum number of distinct peers, bucketed values). Use this **alongside** kaax_suggest_configuration so the user gets both the curated preset and the crowd-sourced reference.",
735
+ inputSchema: zodToJsonSchema(PeerSuggestionsArgs),
736
+ async handler(args, { client }) {
737
+ return withPaidTier(client, async () => {
738
+ const { type } = PeerSuggestionsArgs.parse(args);
739
+ const result = await client.peerSuggestions(type);
740
+ if (!result) {
741
+ return text("The peer-suggestions endpoint is not available on this Kaax deployment. Use kaax_suggest_configuration to get the curated preset instead.");
742
+ }
743
+ if (!result.suggestion) {
744
+ return text(`Not enough anonymised samples yet (have ${result.samples}, need ${result.minRequired ?? "more"}).`, "", "Fall back to kaax_suggest_configuration for the curated preset.");
745
+ }
746
+ return text(`Peer reference for ${type} (n=${result.samples} anonymised users):`, "", JSON.stringify(result.suggestion, null, 2), "", result.notice ?? "Use this as a starting point, then tune to your crop and region.");
747
+ });
748
+ },
749
+ },
750
+ // ── Download permission (HubSpot-gated) ──────────────────────────────
751
+ {
752
+ name: "kaax_request_download",
753
+ description: "Request permission to download the Kaax Labeling / Desktop binaries. Submits a HubSpot form server-side using the authenticated user's info. Idempotent: calling twice within 24 hours returns the existing pending request. Use this when the user wants the local labeling / desktop apps.",
754
+ inputSchema: zodToJsonSchema(RequestDownloadArgs),
755
+ async handler(args, { client }) {
756
+ try {
757
+ const parsed = RequestDownloadArgs.parse(args);
758
+ const result = await client.requestDownload(parsed);
759
+ if (result.status === "approved") {
760
+ return text("✅ Permission is already granted on this account.", "Call kaax_check_download_status to get the binary URLs.");
761
+ }
762
+ return text(`⌛ Request submitted${result.idempotent ? " (existing pending request)" : ""}.`, "", result.requestId ? `Request ID: ${result.requestId}` : "", "", result.message ?? "We will notify you by email when an admin approves it.", "", "Poll kaax_check_download_status every minute or so to detect approval.");
763
+ }
764
+ catch (err) {
765
+ return errorText(err);
766
+ }
767
+ },
768
+ },
769
+ {
770
+ name: "kaax_check_download_status",
771
+ description: "Check the status of the user's download-permission request. Returns `pending`, `approved`, `rejected`, `not_requested`, or `needs_activation`. When approved, the response includes the latest stable binary URLs.",
772
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
773
+ async handler(_args, { client }) {
774
+ try {
775
+ const result = await client.downloadStatus();
776
+ if (result.status === "approved") {
777
+ const labeling = result.binaries.kaaxLabeling;
778
+ const desktop = result.binaries.kaaxDesktop;
779
+ return text("✅ Download permission granted.", "", labeling
780
+ ? `Kaax Labeling · v${labeling.version ?? "?"} · ${labeling.fileName}\n ${labeling.url}`
781
+ : "Kaax Labeling · no stable build available", "", desktop
782
+ ? `Kaax Desktop · v${desktop.version ?? "?"} · ${desktop.fileName}\n ${desktop.url}`
783
+ : "Kaax Desktop · no stable build available", "", "Use kaax_download_kaax to fetch a binary directly to disk.");
784
+ }
785
+ if (result.status === "needs_activation") {
786
+ return text("⚠️ Activate your account via the email we sent before downloads can be granted.");
787
+ }
788
+ if (result.status === "rejected") {
789
+ return text(`❌ Request rejected${result.rejectedReason ? `: ${result.rejectedReason}` : "."}`, "Reach out to support if you think this is a mistake.");
790
+ }
791
+ if (result.status === "not_requested") {
792
+ return text("ℹ️ No download request on file. Call kaax_request_download first.");
793
+ }
794
+ return text("⌛ Request still pending. Try again in a minute or two.", result.requestId ? `Request ID: ${result.requestId}` : "");
795
+ }
796
+ catch (err) {
797
+ return errorText(err);
798
+ }
799
+ },
800
+ },
801
+ {
802
+ name: "kaax_download_kaax",
803
+ description: "Download a Kaax binary (kaaxLabeling or kaaxDesktop) directly to disk. Requires that the user has been approved for downloads. Defaults to the user's home Downloads folder unless an outputDir is provided. Streams the file — never buffers it in memory.",
804
+ inputSchema: zodToJsonSchema(DownloadKaaxArgs),
805
+ async handler(args, { client }) {
806
+ try {
807
+ const { product, outputDir } = DownloadKaaxArgs.parse(args);
808
+ const status = await client.downloadStatus();
809
+ if (status.status !== "approved") {
810
+ return text(`❌ Cannot download: status is "${status.status}". Run kaax_request_download → kaax_check_download_status first.`);
811
+ }
812
+ const info = status.binaries[product];
813
+ if (!info) {
814
+ return text(`❌ No stable build is currently published for ${product}. Try again later or pick the other product.`);
815
+ }
816
+ const destDir = outputDir || join(homedir(), "Downloads");
817
+ const destPath = join(destDir, info.fileName);
818
+ const result = await downloadFile(info.url, destPath);
819
+ return text(`✅ Downloaded ${product} → ${result.outputPath}`, `Size: ${(result.bytesWritten / 1024 / 1024).toFixed(1)} MB`, info.version ? `Version: ${info.version}` : "", "", "On Windows, you may need to right-click → Properties → Unblock the .exe before running it.");
820
+ }
821
+ catch (err) {
822
+ return errorText(err);
823
+ }
824
+ },
825
+ },
826
+ // ── Data conversion ────────────────────────────────────────────────
827
+ {
828
+ name: "kaax_convert_shapefile_to_kml",
829
+ description: "[Free] Converts a local Shapefile (.shp + .dbf) into a Kaax-compatible KML 2.2 file. Runs entirely on the user's machine — no API call, no Pro subscription needed. Returns the output path and any per-feature warnings. Call this when the user mentions shapefiles, .shp, ESRI files, or asks 'how do I import a shp into Kaax'.",
830
+ inputSchema: zodToJsonSchema(ConvertShpArgs),
831
+ async handler(args) {
832
+ try {
833
+ const { shpPath, outputPath } = ConvertShpArgs.parse(args);
834
+ const result = await convertShapefileToKml(shpPath, outputPath);
835
+ const lines = [
836
+ `✅ Wrote ${result.featuresWritten} features to ${result.outputPath}`,
837
+ ];
838
+ if (result.warnings.length) {
839
+ lines.push("", "Warnings:");
840
+ for (const w of result.warnings)
841
+ lines.push(` • ${w}`);
842
+ }
843
+ lines.push("", "Next: upload the .kml at /dashboard/manage → Tus Campos → Nuevo campo.");
844
+ return text(...lines);
845
+ }
846
+ catch (err) {
847
+ return errorText(err);
848
+ }
849
+ },
850
+ },
851
+ ];
852
+ }
853
+ // ─────────────────────────────────────────────────────────────────────────
854
+ // Tiny zod-to-json-schema (we only use the shapes the MCP needs).
855
+ // Keeps us off a heavier dep.
856
+ // ─────────────────────────────────────────────────────────────────────────
857
+ function zodToJsonSchema(schema) {
858
+ if (schema instanceof z.ZodObject) {
859
+ const shape = schema.shape;
860
+ const properties = {};
861
+ const required = [];
862
+ for (const [key, value] of Object.entries(shape)) {
863
+ properties[key] = zodToJsonSchema(value);
864
+ if (!value.isOptional())
865
+ required.push(key);
866
+ }
867
+ return {
868
+ type: "object",
869
+ properties,
870
+ ...(required.length ? { required } : {}),
871
+ additionalProperties: false,
872
+ };
873
+ }
874
+ if (schema instanceof z.ZodString) {
875
+ const out = { type: "string" };
876
+ if (schema.description)
877
+ out.description = schema.description;
878
+ return out;
879
+ }
880
+ if (schema instanceof z.ZodNumber) {
881
+ const out = { type: "number" };
882
+ if (schema.description)
883
+ out.description = schema.description;
884
+ return out;
885
+ }
886
+ if (schema instanceof z.ZodBoolean) {
887
+ return { type: "boolean" };
888
+ }
889
+ if (schema instanceof z.ZodEnum) {
890
+ const out = {
891
+ type: "string",
892
+ enum: schema.options,
893
+ };
894
+ if (schema.description)
895
+ out.description = schema.description;
896
+ return out;
897
+ }
898
+ if (schema instanceof z.ZodOptional) {
899
+ return zodToJsonSchema(schema.unwrap());
900
+ }
901
+ if (schema instanceof z.ZodUnion) {
902
+ return {
903
+ oneOf: schema.options.map(zodToJsonSchema),
904
+ ...(schema.description ? { description: schema.description } : {}),
905
+ };
906
+ }
907
+ return {}; // permissive fallback
908
+ }
909
+ // ─────────────────────────────────────────────────────────────────────────
910
+ // Internal helpers
911
+ // ─────────────────────────────────────────────────────────────────────────
912
+ function groupBy(items, key) {
913
+ return items.reduce((acc, item) => {
914
+ const k = key(item);
915
+ (acc[k] ||= []).push(item);
916
+ return acc;
917
+ }, {});
918
+ }
919
+ function uniqueTags(records) {
920
+ const set = new Set();
921
+ for (const r of records)
922
+ for (const t of r.tag ?? [])
923
+ set.add(t);
924
+ return [...set];
925
+ }
926
+ //# sourceMappingURL=tools.js.map