@carrierllc/mcp 0.2.1 → 0.2.3

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