@trillet-ai/mcp 0.7.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.
@@ -0,0 +1,391 @@
1
+ import { z } from "zod";
2
+ import { ok, guard, workspaceParam } from "../helpers.js";
3
+ /**
4
+ * Call-history rows carry epoch-seconds in `timestamp` (there is no startTime
5
+ * field) — but the underlying startTime is written in mixed units by different
6
+ * services (backend TRI-839), and web test calls have been seen with the value
7
+ * off by exactly 1000x low. Detect the scale instead of trusting the unit.
8
+ */
9
+ function toIso(epoch) {
10
+ if (typeof epoch !== "number" || !Number.isFinite(epoch) || epoch <= 0)
11
+ return null;
12
+ let ms;
13
+ if (epoch >= 1e12)
14
+ ms = epoch; // already milliseconds
15
+ else if (epoch >= 1e9)
16
+ ms = epoch * 1000; // seconds (the documented unit)
17
+ else if (epoch >= 1e6)
18
+ ms = epoch * 1e6; // seconds/1000 (buggy web-call rows)
19
+ else
20
+ return null;
21
+ return new Date(ms).toISOString();
22
+ }
23
+ /**
24
+ * Billing fields on a call row. customer_charge already INCLUDES the warm-transfer
25
+ * infra charge; warmTransferCharge/Minutes are the stored line-item split.
26
+ */
27
+ function costFields(c) {
28
+ return {
29
+ cost: c.cost,
30
+ chargedDuration: c.charged_duration,
31
+ telephonyCost: c.telephonyCost,
32
+ warmTransferCharge: c.warmTransferCharge,
33
+ warmTransferMinutes: c.warmTransferMinutes,
34
+ };
35
+ }
36
+ const filterParams = {
37
+ agent_id: z.string().optional(),
38
+ flow_id: z.string().optional().describe("Filter to one call flow (pathway) id"),
39
+ direction: z.enum(["inbound", "outbound", "web"]).optional(),
40
+ status: z.string().optional(),
41
+ call_type: z.enum(["sip", "web"]).optional().describe("sip = real phone calls, web = browser calls"),
42
+ search: z.string().optional().describe("Free-text search (numbers, names, call ids)"),
43
+ from_date: z.string().optional().describe("ISO date, inclusive start of the window"),
44
+ to_date: z.string().optional().describe("ISO date, inclusive end of the window"),
45
+ };
46
+ function filterQuery(a) {
47
+ return {
48
+ agentId: a.agent_id,
49
+ pathwayId: a.flow_id,
50
+ direction: a.direction,
51
+ status: a.status,
52
+ callType: a.call_type,
53
+ search: a.search,
54
+ dateRange: a.from_date || a.to_date
55
+ ? JSON.stringify({
56
+ startDate: a.from_date ?? "1970-01-01",
57
+ endDate: a.to_date ?? new Date().toISOString(),
58
+ })
59
+ : undefined,
60
+ };
61
+ }
62
+ /** Heuristic markers a person would look for when skimming a bad call. */
63
+ const HUMAN_REQUEST = /\b(speak|talk|put me through|transfer me|connect me)\b.{0,30}\b(person|human|someone|somebody|real|representative|agent|staff|manager|operator|receptionist)\b|\b(a|an) (real )?(person|human)\b/i;
64
+ const MISUNDERSTOOD = /\b(sorry|apologi[sz]e|didn'?t (catch|get|hear|understand)|could you (repeat|say that again)|not sure i (understood|got that)|one more time)\b/i;
65
+ const TRANSFER_FAILED = /\b(fail|no.?answer|busy|declin|timed? ?out|unavailable|not reachable|error|cancell?ed|not found)\b/i;
66
+ /** transfer_call looks the destination up by exact friendly_name; a prompt that names a person or alias fails here. */
67
+ const TRANSFER_NAME_MISMATCH = /Team '.*' not found/i;
68
+ const SILENCE_END = /silence|inactiv|unresponsive|no.?input|no.?response/i;
69
+ const VOICEMAIL = /voicemail|voice mail|answering machine|leave a message/i;
70
+ const clip = (t, n = 160) => {
71
+ const s = String(t ?? "").replace(/\s+/g, " ").trim();
72
+ return s.length > n ? `${s.slice(0, n - 1)}…` : s;
73
+ };
74
+ /**
75
+ * Compact digest of one call for trillet_review_calls: outcome, transfers and
76
+ * the transcript moments worth a human's attention. Pure, so it is testable.
77
+ */
78
+ export function reviewCall(c) {
79
+ const transcript = Array.isArray(c.transcript)
80
+ ? c.transcript.map((t) => ({ role: String(t.role ?? ""), content: String(t.content ?? "") }))
81
+ : [];
82
+ const flags = [];
83
+ const excerpts = [];
84
+ const excerptAt = (i) => {
85
+ const lines = transcript.slice(Math.max(0, i - 1), i + 2).map((t) => `[${t.role}] ${clip(t.content)}`);
86
+ const text = lines.join(" / ");
87
+ if (text && excerpts.length < 4 && !excerpts.includes(text))
88
+ excerpts.push(text);
89
+ };
90
+ const duration = typeof c.duration === "number" ? c.duration : undefined;
91
+ const functionCalls = Array.isArray(c.functionCalls) ? c.functionCalls : [];
92
+ const transfers = functionCalls
93
+ .filter((f) => /transfer/i.test(String(f.name ?? f.function ?? "")))
94
+ .map((f) => {
95
+ // Call rows store the tool reply in `output`; older shapes used `result`.
96
+ const raw = f.output ?? f.result;
97
+ const result = clip(typeof raw === "string" ? raw : JSON.stringify(raw ?? ""), 120);
98
+ let args = f.arguments;
99
+ if (typeof args === "string") {
100
+ try {
101
+ args = JSON.parse(args);
102
+ }
103
+ catch {
104
+ /* keep the raw string */
105
+ }
106
+ }
107
+ const destination = args && typeof args === "object" ? args.team_name : undefined;
108
+ return { name: f.name ?? f.function, destination, result };
109
+ });
110
+ // Some rows carry the literal strings "None" or "null" in error.
111
+ const errorText = String(c.error ?? "").trim();
112
+ const hasError = errorText !== "" && !/^(none|null|undefined|false)$/i.test(errorText);
113
+ if (c._detailError)
114
+ flags.push("detail_unavailable");
115
+ if (hasError || /fail|error/i.test(String(c.status ?? "")))
116
+ flags.push("error");
117
+ // Browser test calls are short by nature; only phone calls count as suspiciously short.
118
+ if (duration !== undefined && duration < 20 && c.direction !== "web")
119
+ flags.push("very_short");
120
+ // A cold transfer ends the AI side of the call by design; an empty tool reply
121
+ // means the handoff went through, so that is not an early hang-up.
122
+ const coldHandoff = /transfer_cold/i.test(String(c.callEndReason ?? "")) &&
123
+ transfers.length > 0 &&
124
+ transfers[transfers.length - 1].result.replace(/"/g, "").trim() === "";
125
+ if (c.callEndedBy === "agent" && duration !== undefined && duration < 45 && !coldHandoff) {
126
+ flags.push("agent_ended_early");
127
+ }
128
+ if (SILENCE_END.test(String(c.callEndReason ?? "")))
129
+ flags.push("ended_on_silence");
130
+ // analyzedData carries keys like voicemail_left: false; only a true value or
131
+ // the words in the end reason / summary mean a voicemail actually happened.
132
+ const analyzed = c.analyzedData && typeof c.analyzedData === "object" ? c.analyzedData : {};
133
+ const voicemailFlag = Object.entries(analyzed).some(([k, v]) => /voicemail|answering_machine/i.test(k) && (v === true || v === "true"));
134
+ if (voicemailFlag || VOICEMAIL.test(`${c.callEndReason ?? ""} ${c.summary ?? ""}`))
135
+ flags.push("voicemail");
136
+ const failedTransfers = transfers.filter((t) => TRANSFER_FAILED.test(t.result));
137
+ if (failedTransfers.length)
138
+ flags.push("transfer_failed");
139
+ if (transfers.some((t) => TRANSFER_NAME_MISMATCH.test(t.result)))
140
+ flags.push("transfer_name_mismatch");
141
+ // Failed transfer, then the call died on silence: the prompt told the agent
142
+ // not to speak once a transfer starts and gave it no failure branch.
143
+ if (failedTransfers.length && SILENCE_END.test(String(c.callEndReason ?? ""))) {
144
+ flags.push("silent_after_failed_transfer");
145
+ }
146
+ let misunderstood = 0;
147
+ transcript.forEach((t, i) => {
148
+ const isUser = /user|caller|customer|human/i.test(t.role);
149
+ const isAgent = /agent|assistant|bot|ai/i.test(t.role);
150
+ if (isUser && HUMAN_REQUEST.test(t.content)) {
151
+ if (transfers.length === 0 && !flags.includes("asked_for_human_no_transfer")) {
152
+ flags.push("asked_for_human_no_transfer");
153
+ }
154
+ excerptAt(i);
155
+ }
156
+ if (isAgent && MISUNDERSTOOD.test(t.content)) {
157
+ misunderstood += 1;
158
+ if (misunderstood <= 2)
159
+ excerptAt(i);
160
+ }
161
+ });
162
+ if (misunderstood >= 2)
163
+ flags.push("repeated_misunderstanding");
164
+ if (flags.includes("transfer_failed") || flags.includes("agent_ended_early")) {
165
+ excerptAt(Math.max(0, transcript.length - 2));
166
+ }
167
+ return {
168
+ id: c._id,
169
+ time: toIso(c.timestamp) ?? (c.startTime ? new Date(c.startTime).toISOString() : null),
170
+ direction: c.direction,
171
+ duration,
172
+ status: c.status,
173
+ endedBy: c.callEndedBy,
174
+ endReason: c.callEndReason,
175
+ transfers,
176
+ flags,
177
+ excerpts,
178
+ summary: clip(c.summary, 200) || undefined,
179
+ };
180
+ }
181
+ export function registerCallTools(server, client) {
182
+ server.registerTool("trillet_list_calls", {
183
+ title: "List call history",
184
+ description: "List recent calls in the workspace (newest first) with time, duration, status, and full billing fields per call: cost (customer charge in USD, includes any warm-transfer charge), chargedDuration, telephonyCost, warmTransferCharge + warmTransferMinutes (flat-rate human-to-human leg after a warm/whisper transfer, stored as its own line item). Filterable by agent, flow, direction, status, call type, date window, and free text. Use trillet_get_call for one call's transcript and analysis, trillet_get_call_metrics for aggregates.",
185
+ inputSchema: {
186
+ ...filterParams,
187
+ page: z.number().default(1),
188
+ limit: z.number().default(15),
189
+ ...workspaceParam,
190
+ },
191
+ annotations: { readOnlyHint: true, openWorldHint: true },
192
+ }, guard(async (a) => {
193
+ const res = await client.request("GET", "/v2/api/call-history/", {
194
+ query: {
195
+ ...filterQuery(a),
196
+ page: a.page,
197
+ limit: a.limit,
198
+ slim: "true",
199
+ includeTranscripts: "false",
200
+ },
201
+ workspaceId: a.workspace_id,
202
+ });
203
+ const calls = res.data ?? [];
204
+ return ok({
205
+ totalCount: res.pagination?.totalCount,
206
+ calls: calls.map((c) => ({
207
+ id: c._id,
208
+ time: toIso(c.timestamp),
209
+ direction: c.direction,
210
+ duration: c.duration,
211
+ status: c.status,
212
+ agent: c.agent,
213
+ callFlow: c.callFlowName,
214
+ from: c.fromPhoneNumber,
215
+ to: c.toPhoneNumber,
216
+ batchId: c.batchId,
217
+ ...costFields(c),
218
+ })),
219
+ });
220
+ }));
221
+ server.registerTool("trillet_get_call_metrics", {
222
+ title: "Get call metrics (aggregates)",
223
+ description: "Aggregate call-history metrics for a filter window without paging individual calls: totalCalls, avgDuration (m:ss string), completionRate. For exact minutes per month use trillet_get_usage include_minutes_breakdown=true (last 3 months) or sum durations from trillet_list_calls; for what was actually charged use trillet_list_transactions.",
224
+ inputSchema: { ...filterParams, ...workspaceParam },
225
+ annotations: { readOnlyHint: true, openWorldHint: true },
226
+ }, guard(async (a) => {
227
+ const res = await client.request("GET", "/v2/api/call-history/metrics", {
228
+ query: filterQuery(a),
229
+ workspaceId: a.workspace_id,
230
+ });
231
+ return ok(res);
232
+ }));
233
+ server.registerTool("trillet_get_call", {
234
+ title: "Get call transcript and analysis",
235
+ description: "Fetch one call: transcript, post-call analyzedData, summary, recording URL, and the billing breakdown (cost = customer charge incl. warm-transfer component; warmTransferCharge/Minutes split out; agentSegments for multi-agent calls; transferTiming). include_function_calls=true adds the in-call tool/function invocations (including IVR navigator decisions and DTMF sends, invokedBy 'ivr_navigator'). The primary debugging tool.",
236
+ inputSchema: {
237
+ call_id: z.string(),
238
+ include_function_calls: z
239
+ .boolean()
240
+ .default(false)
241
+ .describe("Include functionCalls: every in-call tool invocation with args and results (can be long)"),
242
+ ...workspaceParam,
243
+ },
244
+ annotations: { readOnlyHint: true, openWorldHint: true },
245
+ }, guard(async ({ call_id, include_function_calls, workspace_id }) => {
246
+ const res = await client.request("GET", `/v2/api/call-history/${call_id}`, {
247
+ workspaceId: workspace_id,
248
+ });
249
+ const c = res.callHistory ?? res.data ?? res;
250
+ const out = {
251
+ id: c._id,
252
+ time: toIso(c.timestamp),
253
+ direction: c.direction,
254
+ duration: c.duration,
255
+ status: c.status,
256
+ error: c.error,
257
+ agent: c.agent,
258
+ callFlow: c.callFlowName,
259
+ from: c.fromPhoneNumber,
260
+ to: c.toPhoneNumber,
261
+ ...costFields(c),
262
+ transferTiming: c.transferTiming,
263
+ agentSegments: c.agentSegments,
264
+ summary: c.summary,
265
+ analyzedData: c.analyzedData,
266
+ recordingUrl: c.recordingUrl ?? c.publicRecordingUrl,
267
+ transcript: Array.isArray(c.transcript)
268
+ ? c.transcript.map((t) => `[${t.role}] ${t.content}`)
269
+ : c.transcript,
270
+ };
271
+ if (include_function_calls)
272
+ out.functionCalls = c.functionCalls;
273
+ return ok(out);
274
+ }));
275
+ server.registerTool("trillet_review_calls", {
276
+ title: "Review recent calls for problems",
277
+ description: "Read the recent calls of one flow (flow_id), one agent (agent_id) or the whole workspace and return a compact problem digest: per call the outcome, who ended it and why, transfers and how they went (destination + tool reply; an empty reply on a cold transfer means the call was handed off), and the flagged moments (the caller asked for a person but no transfer happened, transfer_failed, transfer_name_mismatch = the agent asked for a destination that is not an exact transfer friendly_name, silent_after_failed_transfer, the agent kept saying it did not understand, the call was very short or ended in silence, voicemail, errors) with the transcript lines around each. Use it for \"what went wrong\", \"how are calls going\", \"improve the agent from real calls\"; then group the findings, cite call ids, and propose or apply fixes. Use trillet_get_call for a full transcript when a flag needs more context.",
278
+ inputSchema: {
279
+ flow_id: z.string().optional().describe("Review one call flow (pathway) id"),
280
+ agent_id: z.string().optional(),
281
+ hours: z.number().min(1).max(720).default(24).describe("Look-back window in hours (default 24, max 30 days)"),
282
+ limit: z.number().min(1).max(40).default(20).describe("How many of the most recent calls to read in detail"),
283
+ include_ok: z.boolean().default(false).describe("Also list the calls that raised no flag"),
284
+ ...workspaceParam,
285
+ },
286
+ annotations: { readOnlyHint: true, openWorldHint: true },
287
+ }, guard(async (a) => {
288
+ const to = new Date();
289
+ const from = new Date(to.getTime() - a.hours * 3600 * 1000);
290
+ const list = await client.request("GET", "/v2/api/call-history/", {
291
+ query: {
292
+ pathwayId: a.flow_id,
293
+ agentId: a.agent_id,
294
+ dateRange: JSON.stringify({ startDate: from.toISOString(), endDate: to.toISOString() }),
295
+ page: 1,
296
+ limit: a.limit,
297
+ slim: "true",
298
+ includeTranscripts: "false",
299
+ },
300
+ workspaceId: a.workspace_id,
301
+ });
302
+ const rows = list.data ?? [];
303
+ const details = [];
304
+ for (let i = 0; i < rows.length; i += 5) {
305
+ // Five at a time: enough to be quick, gentle on the API.
306
+ // eslint-disable-next-line no-await-in-loop
307
+ const batch = await Promise.all(rows.slice(i, i + 5).map(async (r) => {
308
+ try {
309
+ const res = await client.request("GET", `/v2/api/call-history/${r._id}`, {
310
+ workspaceId: a.workspace_id,
311
+ });
312
+ return res.callHistory ?? res.data ?? res;
313
+ }
314
+ catch {
315
+ return { ...r, _detailError: true };
316
+ }
317
+ }));
318
+ details.push(...batch);
319
+ }
320
+ const reviewed = details.map(reviewCall);
321
+ const flagged = reviewed.filter((c) => c.flags.length > 0);
322
+ const byStatus = {};
323
+ const endedBy = {};
324
+ const flagCounts = {};
325
+ let totalDuration = 0;
326
+ reviewed.forEach((c) => {
327
+ byStatus[c.status ?? "unknown"] = (byStatus[c.status ?? "unknown"] ?? 0) + 1;
328
+ endedBy[c.endedBy ?? "unknown"] = (endedBy[c.endedBy ?? "unknown"] ?? 0) + 1;
329
+ totalDuration += c.duration ?? 0;
330
+ c.flags.forEach((f) => {
331
+ flagCounts[f] = (flagCounts[f] ?? 0) + 1;
332
+ });
333
+ });
334
+ return ok({
335
+ window: { hours: a.hours, from: from.toISOString(), to: to.toISOString() },
336
+ totals: {
337
+ callsInWindow: list.pagination?.totalCount ?? rows.length,
338
+ reviewed: reviewed.length,
339
+ flagged: flagged.length,
340
+ avgDurationSec: reviewed.length ? Math.round(totalDuration / reviewed.length) : 0,
341
+ byStatus,
342
+ endedBy,
343
+ flagCounts,
344
+ },
345
+ flagged,
346
+ ok: a.include_ok
347
+ ? reviewed.filter((c) => c.flags.length === 0)
348
+ : { count: reviewed.length - flagged.length },
349
+ note: reviewed.length === 0
350
+ ? "No calls in this window. Widen hours or check the flow id."
351
+ : "Group the flags into at most three problems, cite the call ids and excerpts as evidence, and say what change fixes each. Flags are heuristics: read the excerpt before concluding.",
352
+ });
353
+ }));
354
+ server.registerTool("trillet_start_outbound_call", {
355
+ title: "Start outbound call (REAL CALL)",
356
+ description: "Place a REAL outbound phone call from an agent to a phone number. This dials a real person and consumes call minutes. Always confirm with the user before calling; requires confirm=true.",
357
+ inputSchema: {
358
+ to: z.string().describe("Destination number in E.164 format, e.g. +61412345678"),
359
+ agent_id: z.string(),
360
+ dynamic_variables: z
361
+ .record(z.string(), z.string())
362
+ .optional()
363
+ .describe("Per-call variables substituted into the prompt as {{key}}"),
364
+ from_phone_number_id: z.string().optional(),
365
+ confirm: z
366
+ .boolean()
367
+ .describe("Must be true. Only set after the user has explicitly approved this call."),
368
+ ...workspaceParam,
369
+ },
370
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
371
+ }, guard(async ({ to, agent_id, dynamic_variables, from_phone_number_id, confirm, workspace_id }) => {
372
+ if (!confirm) {
373
+ return ok({
374
+ blocked: true,
375
+ message: "Refusing to place the call: confirm=false. Ask the user to approve dialing " +
376
+ to +
377
+ " and retry with confirm=true.",
378
+ });
379
+ }
380
+ const body = { to, call_agent_id: agent_id };
381
+ if (dynamic_variables)
382
+ body.dynamic_variables = dynamic_variables;
383
+ if (from_phone_number_id)
384
+ body.from_phone_number_id = from_phone_number_id;
385
+ const res = await client.request("POST", "/v1/api/call", {
386
+ body,
387
+ workspaceId: workspace_id,
388
+ });
389
+ return ok(res);
390
+ }));
391
+ }