@carrierllc/mcp 0.0.1 → 0.2.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.
Files changed (131) hide show
  1. package/README.md +216 -3
  2. package/dist/apps/app-state.d.ts +19 -0
  3. package/dist/apps/app-state.d.ts.map +1 -0
  4. package/dist/apps/app-state.js +28 -0
  5. package/dist/apps/app-state.js.map +1 -0
  6. package/dist/apps/balance-topup.d.ts +19 -0
  7. package/dist/apps/balance-topup.d.ts.map +1 -0
  8. package/dist/apps/balance-topup.js +121 -0
  9. package/dist/apps/balance-topup.js.map +1 -0
  10. package/dist/apps/fleet-health-app.d.ts +29 -0
  11. package/dist/apps/fleet-health-app.d.ts.map +1 -0
  12. package/dist/apps/fleet-health-app.js +152 -0
  13. package/dist/apps/fleet-health-app.js.map +1 -0
  14. package/dist/apps/index.d.ts +37 -0
  15. package/dist/apps/index.d.ts.map +1 -0
  16. package/dist/apps/index.js +42 -0
  17. package/dist/apps/index.js.map +1 -0
  18. package/dist/apps/provisioning-wizard.d.ts +11 -0
  19. package/dist/apps/provisioning-wizard.d.ts.map +1 -0
  20. package/dist/apps/provisioning-wizard.js +345 -0
  21. package/dist/apps/provisioning-wizard.js.map +1 -0
  22. package/dist/audit.d.ts +31 -0
  23. package/dist/audit.d.ts.map +1 -0
  24. package/dist/audit.js +42 -0
  25. package/dist/audit.js.map +1 -0
  26. package/dist/billing-thresholds.d.ts +71 -0
  27. package/dist/billing-thresholds.d.ts.map +1 -0
  28. package/dist/billing-thresholds.js +238 -0
  29. package/dist/billing-thresholds.js.map +1 -0
  30. package/dist/billing.d.ts +95 -0
  31. package/dist/billing.d.ts.map +1 -0
  32. package/dist/billing.js +239 -0
  33. package/dist/billing.js.map +1 -0
  34. package/dist/clerk.d.ts +125 -0
  35. package/dist/clerk.d.ts.map +1 -0
  36. package/dist/clerk.js +389 -0
  37. package/dist/clerk.js.map +1 -0
  38. package/dist/client.d.ts +35 -0
  39. package/dist/client.d.ts.map +1 -0
  40. package/dist/client.js +75 -0
  41. package/dist/client.js.map +1 -0
  42. package/dist/credits.d.ts +165 -0
  43. package/dist/credits.d.ts.map +1 -0
  44. package/dist/credits.js +483 -0
  45. package/dist/credits.js.map +1 -0
  46. package/dist/index.d.ts +14 -0
  47. package/dist/index.d.ts.map +1 -0
  48. package/dist/index.js +95 -0
  49. package/dist/index.js.map +1 -0
  50. package/dist/intelligence.d.ts +4 -0
  51. package/dist/intelligence.d.ts.map +1 -0
  52. package/dist/intelligence.js +899 -0
  53. package/dist/intelligence.js.map +1 -0
  54. package/dist/list-recent-ocs-events.d.ts +48 -0
  55. package/dist/list-recent-ocs-events.d.ts.map +1 -0
  56. package/dist/list-recent-ocs-events.js +160 -0
  57. package/dist/list-recent-ocs-events.js.map +1 -0
  58. package/dist/manus-client.d.ts +429 -0
  59. package/dist/manus-client.d.ts.map +1 -0
  60. package/dist/manus-client.js +401 -0
  61. package/dist/manus-client.js.map +1 -0
  62. package/dist/manus-common.d.ts +5 -0
  63. package/dist/manus-common.d.ts.map +1 -0
  64. package/dist/manus-common.js +5 -0
  65. package/dist/manus-common.js.map +1 -0
  66. package/dist/manus-schedule.d.ts +74 -0
  67. package/dist/manus-schedule.d.ts.map +1 -0
  68. package/dist/manus-schedule.js +213 -0
  69. package/dist/manus-schedule.js.map +1 -0
  70. package/dist/manus-usage.d.ts +32 -0
  71. package/dist/manus-usage.d.ts.map +1 -0
  72. package/dist/manus-usage.js +120 -0
  73. package/dist/manus-usage.js.map +1 -0
  74. package/dist/manus-webhook.d.ts +60 -0
  75. package/dist/manus-webhook.d.ts.map +1 -0
  76. package/dist/manus-webhook.js +361 -0
  77. package/dist/manus-webhook.js.map +1 -0
  78. package/dist/pricing-tools.d.ts +26 -0
  79. package/dist/pricing-tools.d.ts.map +1 -0
  80. package/dist/pricing-tools.js +428 -0
  81. package/dist/pricing-tools.js.map +1 -0
  82. package/dist/projects-tools.d.ts +39 -0
  83. package/dist/projects-tools.d.ts.map +1 -0
  84. package/dist/projects-tools.js +545 -0
  85. package/dist/projects-tools.js.map +1 -0
  86. package/dist/prompts.d.ts +7 -0
  87. package/dist/prompts.d.ts.map +1 -0
  88. package/dist/prompts.js +151 -0
  89. package/dist/prompts.js.map +1 -0
  90. package/dist/stripe-connect-tools.d.ts +33 -0
  91. package/dist/stripe-connect-tools.d.ts.map +1 -0
  92. package/dist/stripe-connect-tools.js +504 -0
  93. package/dist/stripe-connect-tools.js.map +1 -0
  94. package/dist/timing-safe-equal.d.ts +5 -0
  95. package/dist/timing-safe-equal.d.ts.map +1 -0
  96. package/dist/timing-safe-equal.js +13 -0
  97. package/dist/timing-safe-equal.js.map +1 -0
  98. package/dist/tools-backlog.d.ts +28 -0
  99. package/dist/tools-backlog.d.ts.map +1 -0
  100. package/dist/tools-backlog.js +315 -0
  101. package/dist/tools-backlog.js.map +1 -0
  102. package/dist/tools-carrier-ask.d.ts +138 -0
  103. package/dist/tools-carrier-ask.d.ts.map +1 -0
  104. package/dist/tools-carrier-ask.js +913 -0
  105. package/dist/tools-carrier-ask.js.map +1 -0
  106. package/dist/tools-pricing.d.ts +18 -0
  107. package/dist/tools-pricing.d.ts.map +1 -0
  108. package/dist/tools-pricing.js +172 -0
  109. package/dist/tools-pricing.js.map +1 -0
  110. package/dist/tools-ui-agent-ask.d.ts +29 -0
  111. package/dist/tools-ui-agent-ask.d.ts.map +1 -0
  112. package/dist/tools-ui-agent-ask.js +272 -0
  113. package/dist/tools-ui-agent-ask.js.map +1 -0
  114. package/dist/tools-ui-agent-schedule.d.ts +29 -0
  115. package/dist/tools-ui-agent-schedule.d.ts.map +1 -0
  116. package/dist/tools-ui-agent-schedule.js +517 -0
  117. package/dist/tools-ui-agent-schedule.js.map +1 -0
  118. package/dist/tools-ui-agent.d.ts +40 -0
  119. package/dist/tools-ui-agent.d.ts.map +1 -0
  120. package/dist/tools-ui-agent.js +508 -0
  121. package/dist/tools-ui-agent.js.map +1 -0
  122. package/dist/tools.d.ts +60 -0
  123. package/dist/tools.d.ts.map +1 -0
  124. package/dist/tools.js +1295 -0
  125. package/dist/tools.js.map +1 -0
  126. package/dist/types.d.ts +291 -0
  127. package/dist/types.d.ts.map +1 -0
  128. package/dist/types.js +5 -0
  129. package/dist/types.js.map +1 -0
  130. package/package.json +55 -7
  131. package/index.js +0 -1
@@ -0,0 +1,899 @@
1
+ import { z } from "zod";
2
+ import { OcsClient } from "./client.js";
3
+ import { getDefaultResellerId } from "./tools.js";
4
+ // ---------------------------------------------------------------------------
5
+ // Helpers
6
+ // ---------------------------------------------------------------------------
7
+ async function safeCall(env, token, method, params = {}) {
8
+ try {
9
+ const client = new OcsClient(env.CARRIER_OCS_BASE_URL, token);
10
+ const data = await client.call(method, params);
11
+ return { data, error: null };
12
+ }
13
+ catch (err) {
14
+ return { data: null, error: err instanceof Error ? err.message : String(err) };
15
+ }
16
+ }
17
+ function formatBytes(bytes) {
18
+ if (bytes === 0)
19
+ return "0 B";
20
+ const units = ["B", "KB", "MB", "GB", "TB"];
21
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
22
+ return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${units[i]}`;
23
+ }
24
+ function daysUntil(dateStr) {
25
+ const now = new Date();
26
+ const target = new Date(dateStr);
27
+ return Math.ceil((target.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
28
+ }
29
+ function toISODate(d) {
30
+ return d.toISOString().split("T")[0];
31
+ }
32
+ function result(text, isError = false) {
33
+ return { content: [{ type: "text", text }], ...(isError ? { isError: true } : {}) };
34
+ }
35
+ // ---------------------------------------------------------------------------
36
+ // 1. DIAGNOSE SUBSCRIBER — "why is this subscriber offline?"
37
+ // ---------------------------------------------------------------------------
38
+ export function registerIntelligenceTools(server, ctx) {
39
+ server.registerTool("diagnose_subscriber", {
40
+ title: "Diagnose Subscriber Issues",
41
+ description: "Smart diagnostic that chains multiple API calls to analyze why a subscriber " +
42
+ "may be offline, throttled, or having connectivity issues. Returns a structured " +
43
+ "diagnosis with root cause analysis and recommended actions.",
44
+ inputSchema: {
45
+ iccid: z.string().describe("The subscriber ICCID to diagnose"),
46
+ },
47
+ annotations: { readOnlyHint: true },
48
+ }, async ({ iccid }) => {
49
+ const token = await ctx.getUserToken(ctx.props.sub);
50
+ const findings = [];
51
+ const actions = [];
52
+ let severity = "healthy";
53
+ // 1. Get subscriber details
54
+ const sub = await safeCall(ctx.env, token, "getSingleSubscriber", { iccid });
55
+ if (sub.error)
56
+ return result(`Failed to fetch subscriber: ${sub.error}`, true);
57
+ if (!sub.data)
58
+ return result("Subscriber not found", true);
59
+ const status = String(sub.data.status ?? "").toUpperCase();
60
+ const balance = Number(sub.data.balance ?? 0);
61
+ // Check OCS status
62
+ if (status !== "ACTIVE") {
63
+ findings.push(`OCS status is ${status} (not ACTIVE)`);
64
+ actions.push(`Reactivate subscriber via modify_subscriber_status`);
65
+ severity = "critical";
66
+ }
67
+ // Check balance
68
+ if (balance <= 0) {
69
+ findings.push(`Balance is ${balance} — subscriber may be blocked from usage`);
70
+ actions.push(`Top up balance via modify_subscriber_balance`);
71
+ if (severity !== "critical")
72
+ severity = "warning";
73
+ }
74
+ // 2. Check SIM provider status — OCS expects bare simId (Long), resolve from subscriber record
75
+ const simId = sub.data.simId ?? sub.data.sim_id ?? sub.data.id;
76
+ const sim = simId !== undefined
77
+ ? await safeCall(ctx.env, token, "getSimProviderStatus", Number(simId))
78
+ : { data: null, error: null };
79
+ if (sim.data) {
80
+ const simStatus = String(sim.data.simStatus ?? sim.data.status ?? "").toUpperCase();
81
+ if (simStatus && !["ENABLED", "ACTIVE", "ACTIVATED"].includes(simStatus)) {
82
+ findings.push(`SIM provider status is ${simStatus} — SIM may be disabled at network level`);
83
+ actions.push(`Enable SIM via change_sim_status`);
84
+ severity = "critical";
85
+ }
86
+ }
87
+ // 3. Check packages
88
+ const pkgs = await safeCall(ctx.env, token, "listSubscriberPrepaidPackages", { iccid });
89
+ if (pkgs.data && Array.isArray(pkgs.data)) {
90
+ const activePkgs = pkgs.data.filter((p) => String(p.status ?? "").toUpperCase() === "ACTIVE");
91
+ if (activePkgs.length === 0) {
92
+ findings.push("No active packages — subscriber has no data/voice/SMS allowance");
93
+ actions.push("Assign a package via assign_package");
94
+ severity = "critical";
95
+ }
96
+ else {
97
+ // Check for depleted packages
98
+ for (const pkg of activePkgs) {
99
+ const dataUsed = Number(pkg.dataUsed ?? pkg.dataConsumed ?? 0);
100
+ const dataLimit = Number(pkg.dataLimit ?? pkg.dataAllowance ?? 0);
101
+ if (dataLimit > 0 && dataUsed >= dataLimit) {
102
+ findings.push(`Package "${pkg.name ?? pkg.packageTemplateId}" data depleted: ` +
103
+ `${formatBytes(dataUsed)} / ${formatBytes(dataLimit)}`);
104
+ actions.push("Assign additional package or increase limits via modify_package_limits");
105
+ if (severity !== "critical")
106
+ severity = "warning";
107
+ }
108
+ // Check expiry
109
+ const expiry = String(pkg.expirationDate ?? pkg.endDate ?? "");
110
+ if (expiry) {
111
+ const days = daysUntil(expiry);
112
+ if (days < 0) {
113
+ findings.push(`Package "${pkg.name ?? pkg.packageTemplateId}" expired ${Math.abs(days)} days ago`);
114
+ actions.push("Remove expired package and assign a new one");
115
+ if (severity !== "critical")
116
+ severity = "warning";
117
+ }
118
+ else if (days <= 3) {
119
+ findings.push(`Package "${pkg.name ?? pkg.packageTemplateId}" expires in ${days} day(s)`);
120
+ actions.push("Consider renewing or assigning a recurring package");
121
+ if (severity === "healthy")
122
+ severity = "info";
123
+ }
124
+ }
125
+ }
126
+ }
127
+ }
128
+ // 4. Check recent network events (last 2 days)
129
+ const now = new Date();
130
+ const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000);
131
+ const events = await safeCall(ctx.env, token, "subscriberNetworkEventsOverPeriod", {
132
+ subscriber: { iccid },
133
+ period: { start: toISODate(twoDaysAgo), end: toISODate(now) },
134
+ });
135
+ if (events.data && Array.isArray(events.data)) {
136
+ if (events.data.length === 0) {
137
+ findings.push("No network events in last 48 hours — device may be powered off or out of coverage");
138
+ if (severity === "healthy")
139
+ severity = "warning";
140
+ }
141
+ else {
142
+ const lastEvent = events.data[events.data.length - 1];
143
+ const lastType = String(lastEvent.eventType ?? lastEvent.type ?? "unknown");
144
+ findings.push(`Last network event: ${lastType} at ${lastEvent.timestamp ?? lastEvent.date ?? "unknown"}`);
145
+ }
146
+ }
147
+ // 5. Check HLR bitrate — OCS expects { imsi }, resolve from subscriber record
148
+ const imsiForBitrate = typeof sub.data.imsi === "string" ? sub.data.imsi : null;
149
+ const bitrate = imsiForBitrate
150
+ ? await safeCall(ctx.env, token, "hlrGetBitrate", { imsi: imsiForBitrate })
151
+ : { data: null, error: null };
152
+ if (bitrate.data) {
153
+ const rate = Number(bitrate.data.bitrate ?? bitrate.data.maxBitrate ?? 0);
154
+ if (rate > 0 && rate < 1000000) {
155
+ findings.push(`HLR bitrate throttled to ${(rate / 1000).toFixed(0)} kbps`);
156
+ actions.push("Increase bitrate via hlr_set_bitrate if throttling is unintended");
157
+ if (severity === "healthy")
158
+ severity = "info";
159
+ }
160
+ }
161
+ // Build report
162
+ if (findings.length === 0) {
163
+ findings.push("No issues detected — subscriber appears healthy");
164
+ }
165
+ const report = [
166
+ `# Subscriber Diagnosis: ${iccid}`,
167
+ ``,
168
+ `## Severity: ${severity.toUpperCase()}`,
169
+ ``,
170
+ `## Findings`,
171
+ ...findings.map((f, i) => `${i + 1}. ${f}`),
172
+ ``,
173
+ ...(actions.length > 0 ? [
174
+ `## Recommended Actions`,
175
+ ...actions.map((a, i) => `${i + 1}. ${a}`),
176
+ ] : []),
177
+ ``,
178
+ `## Raw Status`,
179
+ `- OCS Status: ${status}`,
180
+ `- Balance: ${balance}`,
181
+ `- Active Packages: ${pkgs.data && Array.isArray(pkgs.data) ? pkgs.data.filter((p) => String(p.status ?? "").toUpperCase() === "ACTIVE").length : "unknown"}`,
182
+ ].join("\n");
183
+ return result(report);
184
+ });
185
+ // ---------------------------------------------------------------------------
186
+ // 2. FLEET HEALTH — single-call fleet overview
187
+ // ---------------------------------------------------------------------------
188
+ server.registerTool("fleet_health", {
189
+ title: "Fleet Health Dashboard",
190
+ description: "Aggregates eSIM status counts, low-balance accounts, and provides a " +
191
+ "fleet-wide health summary in a single call. Identifies accounts that " +
192
+ "need attention.",
193
+ inputSchema: {
194
+ accountId: z.number().optional().describe("Filter to a specific account (omit for all)"),
195
+ },
196
+ annotations: { readOnlyHint: true },
197
+ }, async ({ accountId }) => {
198
+ const token = await ctx.getUserToken(ctx.props.sub);
199
+ // Parallel fetch
200
+ const [statusResult, accountsResult] = await Promise.all([
201
+ safeCall(ctx.env, token, "esimStatusPerAccount", accountId !== undefined ? { accountId } : {}),
202
+ safeCall(ctx.env, token, "listResellerAccount", {}),
203
+ ]);
204
+ const sections = ["# Fleet Health Dashboard\n"];
205
+ // eSIM status breakdown
206
+ if (statusResult.data && Array.isArray(statusResult.data)) {
207
+ let totalActive = 0, totalSuspended = 0, totalInventory = 0, totalOther = 0;
208
+ for (const account of statusResult.data) {
209
+ totalActive += Number(account.active ?? 0);
210
+ totalSuspended += Number(account.suspended ?? 0);
211
+ totalInventory += Number(account.inventory ?? account.notActivated ?? 0);
212
+ totalOther += Number(account.other ?? account.terminated ?? 0);
213
+ }
214
+ const total = totalActive + totalSuspended + totalInventory + totalOther;
215
+ const utilization = total > 0 ? ((totalActive / total) * 100).toFixed(1) : "0";
216
+ sections.push(`## eSIM Fleet Status`);
217
+ sections.push(`| Metric | Count | % |`);
218
+ sections.push(`|--------|-------|---|`);
219
+ sections.push(`| Active | ${totalActive} | ${total > 0 ? ((totalActive / total) * 100).toFixed(1) : 0}% |`);
220
+ sections.push(`| Suspended | ${totalSuspended} | ${total > 0 ? ((totalSuspended / total) * 100).toFixed(1) : 0}% |`);
221
+ sections.push(`| Inventory | ${totalInventory} | ${total > 0 ? ((totalInventory / total) * 100).toFixed(1) : 0}% |`);
222
+ sections.push(`| Other | ${totalOther} | ${total > 0 ? ((totalOther / total) * 100).toFixed(1) : 0}% |`);
223
+ sections.push(`| **Total** | **${total}** | |`);
224
+ sections.push(`\n**Fleet Utilization: ${utilization}%**`);
225
+ if (totalSuspended > totalActive * 0.1) {
226
+ sections.push(`\n⚠ High suspension rate (${totalSuspended} suspended vs ${totalActive} active)`);
227
+ }
228
+ }
229
+ // Account balances
230
+ if (accountsResult.data && Array.isArray(accountsResult.data)) {
231
+ const lowBalance = accountsResult.data.filter((a) => Number(a.balance ?? 0) < 10);
232
+ if (lowBalance.length > 0) {
233
+ sections.push(`\n## Low Balance Accounts (< 10)`);
234
+ sections.push(`| Account | Balance |`);
235
+ sections.push(`|---------|---------|`);
236
+ for (const a of lowBalance) {
237
+ sections.push(`| ${a.name ?? a.accountId ?? "?"} | ${Number(a.balance ?? 0).toFixed(2)} |`);
238
+ }
239
+ }
240
+ sections.push(`\n## Account Summary`);
241
+ sections.push(`- Total accounts: ${accountsResult.data.length}`);
242
+ sections.push(`- Low balance (< 10): ${lowBalance.length}`);
243
+ }
244
+ return result(sections.join("\n"));
245
+ });
246
+ // ---------------------------------------------------------------------------
247
+ // 3. USAGE ANOMALY DETECTION
248
+ // ---------------------------------------------------------------------------
249
+ server.registerTool("detect_usage_anomalies", {
250
+ title: "Detect Usage Anomalies",
251
+ description: "Analyzes a subscriber's recent usage patterns over the last 7 days to detect " +
252
+ "anomalies: sudden spikes, unusual off-hours activity, or consumption rates that " +
253
+ "would exhaust the package before expiry.",
254
+ inputSchema: {
255
+ iccid: z.string().describe("The subscriber ICCID to analyze"),
256
+ },
257
+ annotations: { readOnlyHint: true },
258
+ }, async ({ iccid }) => {
259
+ const token = await ctx.getUserToken(ctx.props.sub);
260
+ const now = new Date();
261
+ const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
262
+ const [usageResult, pkgResult] = await Promise.all([
263
+ safeCall(ctx.env, token, "subscriberUsageOverPeriod", {
264
+ subscriber: { iccid },
265
+ period: { start: toISODate(weekAgo), end: toISODate(now) },
266
+ }),
267
+ safeCall(ctx.env, token, "listSubscriberPrepaidPackages", { iccid }),
268
+ ]);
269
+ if (usageResult.error)
270
+ return result(`Failed to fetch usage: ${usageResult.error}`, true);
271
+ const sections = [`# Usage Anomaly Report: ${iccid}\n`];
272
+ const anomalies = [];
273
+ if (usageResult.data && Array.isArray(usageResult.data) && usageResult.data.length > 0) {
274
+ // Extract daily data volumes
275
+ const dailyData = [];
276
+ for (const entry of usageResult.data) {
277
+ const bytes = Number(entry.dataBytes ?? entry.dataVolume ?? entry.totalData ?? 0);
278
+ const date = String(entry.date ?? entry.day ?? "?");
279
+ dailyData.push({ date, bytes });
280
+ }
281
+ if (dailyData.length >= 2) {
282
+ // Calculate stats
283
+ const volumes = dailyData.map(d => d.bytes);
284
+ const mean = volumes.reduce((a, b) => a + b, 0) / volumes.length;
285
+ const stdDev = Math.sqrt(volumes.reduce((sum, v) => sum + Math.pow(v - mean, 2), 0) / volumes.length);
286
+ sections.push(`## Daily Usage (Last 7 Days)`);
287
+ sections.push(`| Date | Data | vs Average |`);
288
+ sections.push(`|------|------|-----------|`);
289
+ for (const d of dailyData) {
290
+ const deviation = mean > 0 ? ((d.bytes - mean) / mean * 100).toFixed(0) : "0";
291
+ const flag = d.bytes > mean + 2 * stdDev ? " SPIKE" :
292
+ d.bytes > mean + stdDev ? " HIGH" : "";
293
+ sections.push(`| ${d.date} | ${formatBytes(d.bytes)} | ${deviation}%${flag} |`);
294
+ if (d.bytes > mean + 2 * stdDev) {
295
+ anomalies.push(`Spike on ${d.date}: ${formatBytes(d.bytes)} (${deviation}% above average)`);
296
+ }
297
+ }
298
+ sections.push(`\n**Average daily usage: ${formatBytes(mean)}**`);
299
+ sections.push(`**Std deviation: ${formatBytes(stdDev)}**`);
300
+ // Burn rate analysis against active packages
301
+ if (pkgResult.data && Array.isArray(pkgResult.data)) {
302
+ const activePkgs = pkgResult.data.filter((p) => String(p.status ?? "").toUpperCase() === "ACTIVE");
303
+ for (const pkg of activePkgs) {
304
+ const dataLimit = Number(pkg.dataLimit ?? pkg.dataAllowance ?? 0);
305
+ const dataUsed = Number(pkg.dataUsed ?? pkg.dataConsumed ?? 0);
306
+ const remaining = dataLimit - dataUsed;
307
+ const expiry = String(pkg.expirationDate ?? pkg.endDate ?? "");
308
+ if (remaining > 0 && expiry && mean > 0) {
309
+ const daysLeft = daysUntil(expiry);
310
+ const daysToExhaust = remaining / mean;
311
+ sections.push(`\n## Burn Rate: ${pkg.name ?? pkg.packageTemplateId}`);
312
+ sections.push(`- Remaining: ${formatBytes(remaining)} of ${formatBytes(dataLimit)}`);
313
+ sections.push(`- Days until expiry: ${daysLeft}`);
314
+ sections.push(`- At current rate, data exhausts in: ${daysToExhaust.toFixed(1)} days`);
315
+ if (daysToExhaust < daysLeft * 0.5) {
316
+ anomalies.push(`Package "${pkg.name ?? pkg.packageTemplateId}" will run out ` +
317
+ `${(daysLeft - daysToExhaust).toFixed(0)} days before expiry at current consumption`);
318
+ }
319
+ }
320
+ }
321
+ }
322
+ }
323
+ }
324
+ else {
325
+ sections.push("No usage data available for the last 7 days.");
326
+ }
327
+ if (anomalies.length > 0) {
328
+ sections.push(`\n## Anomalies Detected`);
329
+ anomalies.forEach((a, i) => sections.push(`${i + 1}. ${a}`));
330
+ }
331
+ else {
332
+ sections.push(`\n## No anomalies detected — usage appears normal.`);
333
+ }
334
+ return result(sections.join("\n"));
335
+ });
336
+ // ---------------------------------------------------------------------------
337
+ // 4. PACKAGE OPTIMIZER — recommend better-fit packages
338
+ // ---------------------------------------------------------------------------
339
+ server.registerTool("optimize_package", {
340
+ title: "Package Optimization Advisor",
341
+ description: "Compares a subscriber's actual usage against their current package and all " +
342
+ "available templates. Recommends better-fit packages to reduce waste or prevent " +
343
+ "overages. Calculates potential savings.",
344
+ inputSchema: {
345
+ iccid: z.string().describe("The subscriber ICCID to optimize"),
346
+ },
347
+ annotations: { readOnlyHint: true },
348
+ }, async ({ iccid }) => {
349
+ const token = await ctx.getUserToken(ctx.props.sub);
350
+ const now = new Date();
351
+ const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
352
+ const [usageResult, pkgResult, templatesResult] = await Promise.all([
353
+ safeCall(ctx.env, token, "subscriberUsageOverPeriod", {
354
+ subscriber: { iccid },
355
+ period: { start: toISODate(weekAgo), end: toISODate(now) },
356
+ }),
357
+ safeCall(ctx.env, token, "listSubscriberPrepaidPackages", { iccid }),
358
+ safeCall(ctx.env, token, "listPrepaidPackageTemplate", {}),
359
+ ]);
360
+ const sections = [`# Package Optimization: ${iccid}\n`];
361
+ // Calculate average daily usage
362
+ let avgDailyData = 0;
363
+ if (usageResult.data && Array.isArray(usageResult.data) && usageResult.data.length > 0) {
364
+ const totalData = usageResult.data.reduce((sum, e) => sum + Number(e.dataBytes ?? e.dataVolume ?? e.totalData ?? 0), 0);
365
+ avgDailyData = totalData / usageResult.data.length;
366
+ sections.push(`## Current Usage Pattern`);
367
+ sections.push(`- Average daily data: ${formatBytes(avgDailyData)}`);
368
+ sections.push(`- Projected monthly: ${formatBytes(avgDailyData * 30)}`);
369
+ }
370
+ // Current packages
371
+ if (pkgResult.data && Array.isArray(pkgResult.data)) {
372
+ const activePkgs = pkgResult.data.filter((p) => String(p.status ?? "").toUpperCase() === "ACTIVE");
373
+ if (activePkgs.length > 0) {
374
+ sections.push(`\n## Current Active Packages`);
375
+ for (const pkg of activePkgs) {
376
+ const dataLimit = Number(pkg.dataLimit ?? pkg.dataAllowance ?? 0);
377
+ const dataUsed = Number(pkg.dataUsed ?? pkg.dataConsumed ?? 0);
378
+ const utilization = dataLimit > 0 ? ((dataUsed / dataLimit) * 100).toFixed(1) : "N/A";
379
+ const price = Number(pkg.price ?? pkg.cost ?? 0);
380
+ sections.push(`\n### ${pkg.name ?? pkg.packageTemplateId}`);
381
+ sections.push(`- Data: ${formatBytes(dataUsed)} / ${formatBytes(dataLimit)} (${utilization}% used)`);
382
+ if (price > 0)
383
+ sections.push(`- Price: ${price.toFixed(2)}`);
384
+ const expiry = String(pkg.expirationDate ?? pkg.endDate ?? "");
385
+ if (expiry)
386
+ sections.push(`- Expires: ${expiry} (${daysUntil(expiry)} days)`);
387
+ // Flag waste
388
+ if (dataLimit > 0 && Number(utilization) < 30) {
389
+ sections.push(`- LOW UTILIZATION — subscriber is using less than 30% of allowance`);
390
+ }
391
+ else if (Number(utilization) > 90) {
392
+ sections.push(`- NEAR LIMIT — subscriber at risk of running out`);
393
+ }
394
+ }
395
+ }
396
+ }
397
+ // Recommend templates
398
+ if (templatesResult.data && Array.isArray(templatesResult.data) && avgDailyData > 0) {
399
+ // Score templates by fit
400
+ const scored = templatesResult.data
401
+ .map((t) => {
402
+ const limit = Number(t.dataLimit ?? t.dataAllowance ?? 0);
403
+ const validity = Number(t.validityDays ?? t.duration ?? 30);
404
+ const price = Number(t.price ?? t.cost ?? 0);
405
+ const projectedUsage = avgDailyData * validity;
406
+ // Fit score: penalize both waste (too much data) and shortage (too little)
407
+ const ratio = limit > 0 ? projectedUsage / limit : 0;
408
+ const fitScore = 1 - Math.abs(1 - ratio); // 1.0 = perfect fit, 0 = terrible
409
+ const costPerGB = limit > 0 && price > 0 ? price / (limit / (1024 * 1024 * 1024)) : Infinity;
410
+ const name = String(t.name ?? t.templateId ?? "?");
411
+ return { name, limit, validity, price, projectedUsage, fitScore, costPerGB, ratio };
412
+ })
413
+ .filter((t) => t.fitScore > 0.3 && t.limit > 0)
414
+ .sort((a, b) => b.fitScore - a.fitScore)
415
+ .slice(0, 5);
416
+ if (scored.length > 0) {
417
+ sections.push(`\n## Recommended Packages (by usage fit)`);
418
+ sections.push(`| Template | Data | Validity | Price | Fit | Projected Use |`);
419
+ sections.push(`|----------|------|----------|-------|-----|--------------|`);
420
+ for (const t of scored) {
421
+ const fitLabel = t.fitScore > 0.8 ? "GREAT" : t.fitScore > 0.6 ? "GOOD" : "OK";
422
+ sections.push(`| ${t.name} | ${formatBytes(t.limit)} | ` +
423
+ `${t.validity}d | ${t.price > 0 ? t.price.toFixed(2) : "?"} | ` +
424
+ `${fitLabel} (${(t.fitScore * 100).toFixed(0)}%) | ${formatBytes(t.projectedUsage)} |`);
425
+ }
426
+ }
427
+ }
428
+ else if (avgDailyData === 0) {
429
+ sections.push(`\n*No usage data available — cannot recommend packages without usage history.*`);
430
+ }
431
+ return result(sections.join("\n"));
432
+ });
433
+ // ---------------------------------------------------------------------------
434
+ // 5. CHURN RISK SCORING
435
+ // ---------------------------------------------------------------------------
436
+ server.registerTool("churn_risk", {
437
+ title: "Churn Risk Assessment",
438
+ description: "Analyzes a subscriber's usage trends, package status, balance, and activity " +
439
+ "to produce a churn risk score (0-100) with contributing factors and retention " +
440
+ "recommendations.",
441
+ inputSchema: {
442
+ iccid: z.string().describe("The subscriber ICCID to assess"),
443
+ },
444
+ annotations: { readOnlyHint: true },
445
+ }, async ({ iccid }) => {
446
+ const token = await ctx.getUserToken(ctx.props.sub);
447
+ const now = new Date();
448
+ const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
449
+ const [subResult, usageResult, pkgResult, activeResult] = await Promise.all([
450
+ safeCall(ctx.env, token, "getSingleSubscriber", { iccid }),
451
+ safeCall(ctx.env, token, "subscriberUsageOverPeriod", {
452
+ subscriber: { iccid },
453
+ period: { start: toISODate(weekAgo), end: toISODate(now) },
454
+ }),
455
+ safeCall(ctx.env, token, "listSubscriberPrepaidPackages", { iccid }),
456
+ safeCall(ctx.env, token, "getSubscriberActivePeriod", { iccid }),
457
+ ]);
458
+ let riskScore = 0;
459
+ const factors = [];
460
+ // Factor 1: Usage trend (declining usage = higher risk)
461
+ if (usageResult.data && Array.isArray(usageResult.data) && usageResult.data.length >= 3) {
462
+ const volumes = usageResult.data.map((e) => Number(e.dataBytes ?? e.dataVolume ?? e.totalData ?? 0));
463
+ const firstHalf = volumes.slice(0, Math.floor(volumes.length / 2));
464
+ const secondHalf = volumes.slice(Math.floor(volumes.length / 2));
465
+ const avgFirst = firstHalf.reduce((a, b) => a + b, 0) / firstHalf.length;
466
+ const avgSecond = secondHalf.reduce((a, b) => a + b, 0) / secondHalf.length;
467
+ if (avgFirst > 0) {
468
+ const trend = (avgSecond - avgFirst) / avgFirst;
469
+ if (trend < -0.5) {
470
+ const impact = 30;
471
+ riskScore += impact;
472
+ factors.push({ factor: "Declining usage", impact, detail: `Usage dropped ${Math.abs(trend * 100).toFixed(0)}% week-over-week` });
473
+ }
474
+ else if (trend < -0.2) {
475
+ const impact = 15;
476
+ riskScore += impact;
477
+ factors.push({ factor: "Moderately declining usage", impact, detail: `Usage dropped ${Math.abs(trend * 100).toFixed(0)}%` });
478
+ }
479
+ }
480
+ }
481
+ else if (!usageResult.data || (Array.isArray(usageResult.data) && usageResult.data.length === 0)) {
482
+ riskScore += 25;
483
+ factors.push({ factor: "No recent usage", impact: 25, detail: "Zero data activity in last 7 days" });
484
+ }
485
+ // Factor 2: Package status
486
+ if (pkgResult.data && Array.isArray(pkgResult.data)) {
487
+ const activePkgs = pkgResult.data.filter((p) => String(p.status ?? "").toUpperCase() === "ACTIVE");
488
+ if (activePkgs.length === 0) {
489
+ riskScore += 20;
490
+ factors.push({ factor: "No active packages", impact: 20, detail: "Subscriber has no active data packages" });
491
+ }
492
+ else {
493
+ // Check if all packages are near expiry
494
+ const allExpiringSoon = activePkgs.every((p) => {
495
+ const expiry = String(p.expirationDate ?? p.endDate ?? "");
496
+ return expiry && daysUntil(expiry) <= 5;
497
+ });
498
+ if (allExpiringSoon) {
499
+ riskScore += 15;
500
+ factors.push({ factor: "All packages expiring soon", impact: 15, detail: "No package renewal in sight" });
501
+ }
502
+ // Check if no recurring packages
503
+ const hasRecurring = activePkgs.some((p) => p.recurring === true || p.isRecurring === true);
504
+ if (!hasRecurring) {
505
+ riskScore += 10;
506
+ factors.push({ factor: "No recurring packages", impact: 10, detail: "Manual renewal required — higher churn risk" });
507
+ }
508
+ }
509
+ }
510
+ // Factor 3: Balance
511
+ if (subResult.data) {
512
+ const balance = Number(subResult.data.balance ?? 0);
513
+ if (balance <= 0) {
514
+ riskScore += 15;
515
+ factors.push({ factor: "Zero balance", impact: 15, detail: "Cannot purchase new packages" });
516
+ }
517
+ }
518
+ // Factor 4: Subscriber age (newer = higher risk)
519
+ if (activeResult.data) {
520
+ const firstUse = String(activeResult.data.firstUseDate ?? activeResult.data.activationDate ?? "");
521
+ if (firstUse) {
522
+ const daysSinceFirst = Math.abs(daysUntil(firstUse));
523
+ if (daysSinceFirst < 30) {
524
+ riskScore += 10;
525
+ factors.push({ factor: "New subscriber", impact: 10, detail: `Only ${daysSinceFirst} days since first use` });
526
+ }
527
+ }
528
+ }
529
+ // Cap at 100
530
+ riskScore = Math.min(riskScore, 100);
531
+ // Risk level
532
+ const level = riskScore >= 70 ? "HIGH" : riskScore >= 40 ? "MEDIUM" : "LOW";
533
+ // Build report
534
+ const sections = [
535
+ `# Churn Risk Assessment: ${iccid}`,
536
+ ``,
537
+ `## Risk Score: ${riskScore}/100 (${level})`,
538
+ ``,
539
+ `${"█".repeat(Math.floor(riskScore / 5))}${"░".repeat(20 - Math.floor(riskScore / 5))}`,
540
+ ``,
541
+ ];
542
+ if (factors.length > 0) {
543
+ sections.push(`## Contributing Factors`);
544
+ sections.push(`| Factor | Impact | Detail |`);
545
+ sections.push(`|--------|--------|--------|`);
546
+ factors.sort((a, b) => b.impact - a.impact);
547
+ for (const f of factors) {
548
+ sections.push(`| ${f.factor} | +${f.impact} | ${f.detail} |`);
549
+ }
550
+ }
551
+ // Retention recommendations
552
+ sections.push(`\n## Retention Recommendations`);
553
+ if (riskScore >= 70) {
554
+ sections.push("1. **Immediate outreach** — contact subscriber with special offer");
555
+ sections.push("2. Assign a complimentary small data package to re-engage");
556
+ sections.push("3. Set up a recurring package to reduce renewal friction");
557
+ }
558
+ else if (riskScore >= 40) {
559
+ sections.push("1. Monitor usage for next 7 days");
560
+ sections.push("2. Consider proactive package renewal notification (via send_sms)");
561
+ sections.push("3. Ensure package fits usage pattern (run optimize_package)");
562
+ }
563
+ else {
564
+ sections.push("1. No immediate action required");
565
+ sections.push("2. Continue monitoring via regular fleet_health checks");
566
+ }
567
+ return result(sections.join("\n"));
568
+ });
569
+ // ---------------------------------------------------------------------------
570
+ // 6. NETWORK COVERAGE AUDIT — "am I on the right networks?"
571
+ // ---------------------------------------------------------------------------
572
+ server.registerTool("audit_network_coverage", {
573
+ title: "Network Coverage Audit",
574
+ description: "Analyzes which networks your subscribers are actually connecting to in a given " +
575
+ "country or across all countries. Compares against your steering lists to identify " +
576
+ "mismatches — subscribers roaming on expensive or non-preferred networks. " +
577
+ "Use this to answer: 'Am I using the right networks in country X?'",
578
+ inputSchema: {
579
+ accountId: z.number().optional().describe("Filter to a specific account"),
580
+ limit: z.number().optional().describe("Max subscribers to sample (default 50)"),
581
+ },
582
+ annotations: { readOnlyHint: true },
583
+ }, async ({ accountId, limit: sampleLimit }) => {
584
+ const token = await ctx.getUserToken(ctx.props.sub);
585
+ const maxSample = sampleLimit ?? 50;
586
+ // Fetch subscribers and steering lists in parallel
587
+ const subParams = { status: "ACTIVE" };
588
+ if (accountId !== undefined)
589
+ subParams.accountId = accountId;
590
+ const [subsResult, resellerId] = await Promise.all([
591
+ safeCall(ctx.env, token, "listSubscriber", subParams),
592
+ getDefaultResellerId(ctx.env, token),
593
+ ]);
594
+ const steeringResult = await safeCall(ctx.env, token, "listSteeringList", resellerId);
595
+ if (subsResult.error)
596
+ return result(`Failed to fetch subscribers: ${subsResult.error}`, true);
597
+ const sections = [`# Network Coverage Audit\n`];
598
+ // Build steering list lookup
599
+ const steeringMap = new Map();
600
+ if (steeringResult.data && Array.isArray(steeringResult.data)) {
601
+ for (const sl of steeringResult.data) {
602
+ steeringMap.set(Number(sl.steeringListId ?? sl.id), sl);
603
+ }
604
+ sections.push(`## Steering Lists: ${steeringMap.size} configured`);
605
+ }
606
+ // Sample subscriber locations and networks
607
+ const countryStats = new Map();
608
+ if (subsResult.data && Array.isArray(subsResult.data)) {
609
+ const subs = subsResult.data.slice(0, maxSample);
610
+ sections.push(`## Sampling ${subs.length} active subscribers\n`);
611
+ // Fetch locations in batches of 10
612
+ const batchSize = 10;
613
+ for (let i = 0; i < subs.length; i += batchSize) {
614
+ const batch = subs.slice(i, i + batchSize);
615
+ const locations = await Promise.all(batch.map((s) => safeCall(ctx.env, token, "getSubscriberLocation", {
616
+ iccid: String(s.iccid ?? ""),
617
+ })));
618
+ for (let j = 0; j < batch.length; j++) {
619
+ const loc = locations[j];
620
+ if (loc.data) {
621
+ const country = String(loc.data.country ?? loc.data.countryCode ?? "Unknown");
622
+ const network = String(loc.data.network ?? loc.data.operator ?? loc.data.mccMnc ?? "Unknown");
623
+ const iccid = String(batch[j].iccid ?? "");
624
+ if (!countryStats.has(country)) {
625
+ countryStats.set(country, { count: 0, networks: new Map(), subscribers: [] });
626
+ }
627
+ const stat = countryStats.get(country);
628
+ stat.count++;
629
+ stat.networks.set(network, (stat.networks.get(network) ?? 0) + 1);
630
+ stat.subscribers.push(iccid);
631
+ }
632
+ }
633
+ }
634
+ }
635
+ // Report by country
636
+ if (countryStats.size > 0) {
637
+ sections.push(`## Network Distribution by Country`);
638
+ const sorted = [...countryStats.entries()].sort((a, b) => b[1].count - a[1].count);
639
+ for (const [country, stat] of sorted) {
640
+ sections.push(`\n### ${country} (${stat.count} subscribers)`);
641
+ sections.push(`| Network | Subscribers | % |`);
642
+ sections.push(`|---------|------------|---|`);
643
+ const networksSorted = [...stat.networks.entries()].sort((a, b) => b[1] - a[1]);
644
+ for (const [network, count] of networksSorted) {
645
+ sections.push(`| ${network} | ${count} | ${((count / stat.count) * 100).toFixed(0)}% |`);
646
+ }
647
+ if (networksSorted.length > 3) {
648
+ sections.push(`\n${networksSorted.length} different networks in ${country} — possible steering fragmentation`);
649
+ }
650
+ }
651
+ }
652
+ else {
653
+ sections.push("No location data available for sampled subscribers.");
654
+ }
655
+ return result(sections.join("\n"));
656
+ });
657
+ // ---------------------------------------------------------------------------
658
+ // 7. MARKETING INTELLIGENCE — "which countries should I target?"
659
+ // ---------------------------------------------------------------------------
660
+ server.registerTool("marketing_intelligence", {
661
+ title: "Marketing Intelligence Report",
662
+ description: "Analyzes your subscriber base to identify high-growth markets, underserved regions, " +
663
+ "and revenue concentration. Answers: 'Which countries should I target with marketing?' " +
664
+ "and 'Where are my most valuable subscribers?'",
665
+ inputSchema: {
666
+ accountId: z.number().optional().describe("Filter to a specific account"),
667
+ },
668
+ annotations: { readOnlyHint: true },
669
+ }, async ({ accountId }) => {
670
+ const token = await ctx.getUserToken(ctx.props.sub);
671
+ const params = {};
672
+ if (accountId !== undefined)
673
+ params.accountId = accountId;
674
+ const [subsResult, templatesResult, resellerId] = await Promise.all([
675
+ safeCall(ctx.env, token, "listSubscriber", params),
676
+ safeCall(ctx.env, token, "listPrepaidPackageTemplate", {}),
677
+ getDefaultResellerId(ctx.env, token),
678
+ ]);
679
+ const zonesResult = await safeCall(ctx.env, token, "listDetailedLocationZone", resellerId);
680
+ const sections = [`# Marketing Intelligence Report\n`];
681
+ // Subscriber distribution by location
682
+ if (subsResult.data && Array.isArray(subsResult.data)) {
683
+ const total = subsResult.data.length;
684
+ sections.push(`## Fleet Size: ${total} subscribers sampled\n`);
685
+ // Get locations for a sample
686
+ const sample = subsResult.data.slice(0, 100);
687
+ const countryData = new Map();
688
+ const batchSize = 10;
689
+ for (let i = 0; i < sample.length; i += batchSize) {
690
+ const batch = sample.slice(i, i + batchSize);
691
+ const locations = await Promise.all(batch.map((s) => safeCall(ctx.env, token, "getSubscriberLocation", {
692
+ iccid: String(s.iccid ?? ""),
693
+ })));
694
+ for (let j = 0; j < batch.length; j++) {
695
+ const loc = locations[j];
696
+ const sub = batch[j];
697
+ const country = loc.data
698
+ ? String(loc.data.country ?? loc.data.countryCode ?? "Unknown")
699
+ : "Unknown";
700
+ if (!countryData.has(country)) {
701
+ countryData.set(country, { subscribers: 0, activePackages: 0, totalBalance: 0 });
702
+ }
703
+ const cd = countryData.get(country);
704
+ cd.subscribers++;
705
+ cd.totalBalance += Number(sub.balance ?? 0);
706
+ }
707
+ }
708
+ if (countryData.size > 0) {
709
+ const sorted = [...countryData.entries()].sort((a, b) => b[1].subscribers - a[1].subscribers);
710
+ sections.push(`## Subscriber Concentration by Country`);
711
+ sections.push(`| Country | Subscribers | % of Fleet | Avg Balance |`);
712
+ sections.push(`|---------|-----------|------------|-------------|`);
713
+ for (const [country, data] of sorted) {
714
+ const pct = ((data.subscribers / sample.length) * 100).toFixed(1);
715
+ const avgBal = (data.totalBalance / data.subscribers).toFixed(2);
716
+ sections.push(`| ${country} | ${data.subscribers} | ${pct}% | ${avgBal} |`);
717
+ }
718
+ // Insights
719
+ sections.push(`\n## Market Insights`);
720
+ // Top market
721
+ const topMarket = sorted[0];
722
+ if (topMarket) {
723
+ sections.push(`- **Strongest market**: ${topMarket[0]} (${topMarket[1].subscribers} subscribers)`);
724
+ if (topMarket[1].subscribers / sample.length > 0.5) {
725
+ sections.push(` Revenue concentration risk — >50% of fleet in one market`);
726
+ }
727
+ }
728
+ // High-value markets (high avg balance)
729
+ const highValue = sorted
730
+ .filter(([, d]) => d.subscribers >= 3)
731
+ .sort((a, b) => (b[1].totalBalance / b[1].subscribers) - (a[1].totalBalance / a[1].subscribers))
732
+ .slice(0, 3);
733
+ if (highValue.length > 0) {
734
+ sections.push(`\n### High-Value Markets (by avg balance)`);
735
+ for (const [country, data] of highValue) {
736
+ sections.push(`- **${country}**: avg balance ${(data.totalBalance / data.subscribers).toFixed(2)} (${data.subscribers} subs)`);
737
+ }
738
+ }
739
+ // Underserved (1-2 subscribers — early traction)
740
+ const emerging = sorted.filter(([, d]) => d.subscribers >= 1 && d.subscribers <= 3);
741
+ if (emerging.length > 0) {
742
+ sections.push(`\n### Emerging Markets (early traction, 1-3 subscribers)`);
743
+ sections.push(`These markets show initial demand — consider targeted campaigns:`);
744
+ for (const [country, data] of emerging) {
745
+ sections.push(`- ${country}: ${data.subscribers} subscriber(s)`);
746
+ }
747
+ }
748
+ }
749
+ }
750
+ // Available coverage vs actual usage
751
+ if (zonesResult.data && Array.isArray(zonesResult.data)) {
752
+ sections.push(`\n## Coverage Catalog`);
753
+ sections.push(`- Location zones available: ${zonesResult.data.length}`);
754
+ }
755
+ if (templatesResult.data && Array.isArray(templatesResult.data)) {
756
+ sections.push(`- Package templates available: ${templatesResult.data.length}`);
757
+ // Price analysis
758
+ const prices = templatesResult.data
759
+ .map((t) => Number(t.price ?? t.cost ?? 0))
760
+ .filter((p) => p > 0);
761
+ if (prices.length > 0) {
762
+ const avgPrice = prices.reduce((a, b) => a + b, 0) / prices.length;
763
+ const minPrice = Math.min(...prices);
764
+ const maxPrice = Math.max(...prices);
765
+ sections.push(`\n### Pricing Range`);
766
+ sections.push(`- Min: ${minPrice.toFixed(2)} | Avg: ${avgPrice.toFixed(2)} | Max: ${maxPrice.toFixed(2)}`);
767
+ }
768
+ }
769
+ sections.push(`\n## Recommended Actions`);
770
+ sections.push(`1. Run \`audit_network_coverage\` to verify network quality in top markets`);
771
+ sections.push(`2. Run \`high_cost_subscribers\` to identify margin pressure`);
772
+ sections.push(`3. Consider creating regional package templates for emerging markets`);
773
+ return result(sections.join("\n"));
774
+ });
775
+ // ---------------------------------------------------------------------------
776
+ // 8. HIGH COST SUBSCRIBERS — "who's costing me money?"
777
+ // ---------------------------------------------------------------------------
778
+ server.registerTool("high_cost_subscribers", {
779
+ title: "High Cost Subscriber Report",
780
+ description: "Identifies subscribers with disproportionately high data consumption relative " +
781
+ "to their package value. Finds subscribers burning through data at rates that " +
782
+ "erode margins. Answers: 'Which subscribers are costing me money?'",
783
+ inputSchema: {
784
+ accountId: z.number().optional().describe("Filter to a specific account"),
785
+ limit: z.number().optional().describe("Max subscribers to analyze (default 100)"),
786
+ thresholdPct: z.number().optional().describe("Usage % threshold to flag (default 80)"),
787
+ },
788
+ annotations: { readOnlyHint: true },
789
+ }, async ({ accountId, limit: maxLimit, thresholdPct }) => {
790
+ const token = await ctx.getUserToken(ctx.props.sub);
791
+ const sampleSize = maxLimit ?? 100;
792
+ const threshold = thresholdPct ?? 80;
793
+ const subParams = { status: "ACTIVE" };
794
+ if (accountId !== undefined)
795
+ subParams.accountId = accountId;
796
+ const subsResult = await safeCall(ctx.env, token, "listSubscriber", subParams);
797
+ if (subsResult.error)
798
+ return result(`Failed to fetch subscribers: ${subsResult.error}`, true);
799
+ if (!subsResult.data || !Array.isArray(subsResult.data))
800
+ return result("No subscribers found", true);
801
+ const sections = [`# High Cost Subscriber Report\n`];
802
+ const now = new Date();
803
+ const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
804
+ const highCostSubs = [];
805
+ // Analyze in batches
806
+ const batchSize = 5;
807
+ const subs = subsResult.data.slice(0, sampleSize);
808
+ for (let i = 0; i < subs.length; i += batchSize) {
809
+ const batch = subs.slice(i, i + batchSize);
810
+ await Promise.all(batch.map(async (sub) => {
811
+ const iccid = String(sub.iccid ?? "");
812
+ const [usage, pkgs, loc] = await Promise.all([
813
+ safeCall(ctx.env, token, "subscriberUsageOverPeriod", {
814
+ subscriber: { iccid },
815
+ period: { start: toISODate(weekAgo), end: toISODate(now) },
816
+ }),
817
+ safeCall(ctx.env, token, "listSubscriberPrepaidPackages", { iccid }),
818
+ safeCall(ctx.env, token, "getSubscriberLocation", { iccid }),
819
+ ]);
820
+ // Calculate daily avg
821
+ let dailyAvgBytes = 0;
822
+ if (usage.data && Array.isArray(usage.data) && usage.data.length > 0) {
823
+ const totalBytes = usage.data.reduce((sum, e) => sum + Number(e.dataBytes ?? e.dataVolume ?? e.totalData ?? 0), 0);
824
+ dailyAvgBytes = totalBytes / usage.data.length;
825
+ }
826
+ // Get active package info
827
+ if (pkgs.data && Array.isArray(pkgs.data)) {
828
+ const activePkg = pkgs.data.find((p) => String(p.status ?? "").toUpperCase() === "ACTIVE");
829
+ if (activePkg && dailyAvgBytes > 0) {
830
+ const dataLimit = Number(activePkg.dataLimit ?? activePkg.dataAllowance ?? 0);
831
+ const dataUsed = Number(activePkg.dataUsed ?? activePkg.dataConsumed ?? 0);
832
+ const price = Number(activePkg.price ?? activePkg.cost ?? 0);
833
+ const utilizationPct = dataLimit > 0 ? (dataUsed / dataLimit) * 100 : 0;
834
+ const remaining = dataLimit - dataUsed;
835
+ const daysToExhaust = dailyAvgBytes > 0 ? remaining / dailyAvgBytes : Infinity;
836
+ const costPerGB = price > 0 && dataUsed > 0
837
+ ? price / (dataUsed / (1024 * 1024 * 1024))
838
+ : 0;
839
+ const country = loc.data
840
+ ? String(loc.data.country ?? loc.data.countryCode ?? "?")
841
+ : "?";
842
+ if (utilizationPct >= threshold || daysToExhaust < 3) {
843
+ highCostSubs.push({
844
+ iccid,
845
+ dailyAvgBytes,
846
+ packageDataLimit: dataLimit,
847
+ packagePrice: price,
848
+ utilizationPct,
849
+ costPerGB,
850
+ daysToExhaust,
851
+ country,
852
+ });
853
+ }
854
+ }
855
+ }
856
+ }));
857
+ }
858
+ // Sort by utilization (highest first)
859
+ highCostSubs.sort((a, b) => b.utilizationPct - a.utilizationPct);
860
+ sections.push(`Analyzed ${subs.length} active subscribers (threshold: ${threshold}% usage)\n`);
861
+ if (highCostSubs.length === 0) {
862
+ sections.push(`No subscribers above ${threshold}% package utilization. Fleet margins look healthy.`);
863
+ }
864
+ else {
865
+ sections.push(`## ${highCostSubs.length} High-Cost Subscribers Found\n`);
866
+ sections.push(`| ICCID | Country | Daily Avg | Usage % | Days Left | Cost/GB |`);
867
+ sections.push(`|-------|---------|-----------|---------|-----------|---------|`);
868
+ let totalDailyBytes = 0;
869
+ for (const s of highCostSubs) {
870
+ totalDailyBytes += s.dailyAvgBytes;
871
+ sections.push(`| ${s.iccid.slice(-8)}... | ${s.country} | ${formatBytes(s.dailyAvgBytes)} | ` +
872
+ `${s.utilizationPct.toFixed(0)}% | ${s.daysToExhaust === Infinity ? "inf" : s.daysToExhaust.toFixed(1)} | ` +
873
+ `${s.costPerGB > 0 ? s.costPerGB.toFixed(2) : "?"} |`);
874
+ }
875
+ sections.push(`\n## Summary`);
876
+ sections.push(`- High-cost subscribers: ${highCostSubs.length} / ${subs.length} (${((highCostSubs.length / subs.length) * 100).toFixed(1)}%)`);
877
+ sections.push(`- Combined daily data burn: ${formatBytes(totalDailyBytes)}`);
878
+ // Country breakdown
879
+ const byCountry = new Map();
880
+ for (const s of highCostSubs) {
881
+ byCountry.set(s.country, (byCountry.get(s.country) ?? 0) + 1);
882
+ }
883
+ const countrySorted = [...byCountry.entries()].sort((a, b) => b[1] - a[1]);
884
+ if (countrySorted.length > 0) {
885
+ sections.push(`\n### By Country`);
886
+ for (const [country, count] of countrySorted) {
887
+ sections.push(`- ${country}: ${count} high-cost subscriber(s)`);
888
+ }
889
+ }
890
+ sections.push(`\n## Recommended Actions`);
891
+ sections.push(`1. Review tariff rates for top countries via \`get_tariff\``);
892
+ sections.push(`2. Consider throttling heavy users via \`hlr_set_bitrate\``);
893
+ sections.push(`3. Run \`optimize_package\` on flagged ICCIDs to find better-fit plans`);
894
+ sections.push(`4. Negotiate better wholesale rates for high-volume countries`);
895
+ }
896
+ return result(sections.join("\n"));
897
+ });
898
+ }
899
+ //# sourceMappingURL=intelligence.js.map