@getdial/cli 0.42.0 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -18,6 +18,7 @@ import { runNumberPurchase } from "./commands/number/purchase.js";
18
18
  import { runNumberSet } from "./commands/number/set.js";
19
19
  import { runNumberWhatsapp } from "./commands/number/whatsapp.js";
20
20
  import { runGroupList } from "./commands/group/list.js";
21
+ import { runLookup } from "./commands/lookup/lookup.js";
21
22
  import { runContactsList } from "./commands/contacts/list.js";
22
23
  import { runMessageSend } from "./commands/message/send.js";
23
24
  import { runMessageReply } from "./commands/message/reply.js";
@@ -337,6 +338,13 @@ program
337
338
  startingAfter: opts.startingAfter,
338
339
  json: !!opts.json,
339
340
  })));
341
+ program
342
+ .command("lookup")
343
+ .argument("<number>", "the phone number to look up, in E.164 (e.g. +14155550123)")
344
+ .description("What channels a phone number can receive on, so you can pick one before sending. Works on " +
345
+ "any number, not just yours. GET /api/v1/lookup.")
346
+ .option("--json", "machine-readable output")
347
+ .action(async (number, opts) => process.exit(await runLookup(number, { json: !!opts.json })));
340
348
  const group = program
341
349
  .command("group")
342
350
  .description("Group conversations your lines are in (WhatsApp).");
@@ -1,6 +1,48 @@
1
1
  import { getCall } from "../../lib/ops/calls.js";
2
2
  import { isDialError } from "../../lib/ops/errors.js";
3
3
  import { printDialError } from "../../lib/cli-error.js";
4
+ /**
5
+ * A silence long enough to call out. Three seconds is past a natural beat between
6
+ * speakers but short enough to catch an agent thinking too long, which is what
7
+ * anyone reading a transcript for pacing is looking for.
8
+ */
9
+ const NOTABLE_PAUSE_MS = 3000;
10
+ /**
11
+ * Plain words for who spoke. "person" rather than "caller" because on an outbound
12
+ * call the human is the callee; "transferred" for the third party a call was
13
+ * handed off to, who is a different human from the one who was on the line first.
14
+ */
15
+ function speakerLabel(speaker) {
16
+ if (speaker === "agent")
17
+ return "agent";
18
+ return speaker === "transfer_target" ? "transferred" : "person";
19
+ }
20
+ /** ms into the call's audio → "m:ss.t", the stamp each transcript line carries. */
21
+ function formatOffset(ms) {
22
+ const minutes = Math.floor(ms / 60000);
23
+ const seconds = Math.floor((ms % 60000) / 1000);
24
+ const tenths = Math.floor((ms % 1000) / 100);
25
+ return `${minutes}:${String(seconds).padStart(2, "0")}.${tenths}`;
26
+ }
27
+ /**
28
+ * Print the transcript with each turn stamped, and call out the long silences
29
+ * between them. The flat string cannot show a pause, which is the whole reason
30
+ * the timed turns exist.
31
+ */
32
+ function printTimedTranscript(turns) {
33
+ console.log(`transcript:`);
34
+ turns.forEach((turn, i) => {
35
+ const previous = turns[i - 1];
36
+ if (previous) {
37
+ const pauseMs = turn.startMs - previous.endMs;
38
+ if (pauseMs >= NOTABLE_PAUSE_MS) {
39
+ console.log(` ... ${(pauseMs / 1000).toFixed(1)}s pause`);
40
+ }
41
+ }
42
+ const speaker = speakerLabel(turn.speaker).padEnd(12);
43
+ console.log(` ${formatOffset(turn.startMs).padStart(7)} ${speaker}${turn.text}`);
44
+ });
45
+ }
4
46
  export async function runCallGet(opts) {
5
47
  try {
6
48
  const c = await getCall(opts.callId);
@@ -19,7 +61,10 @@ export async function runCallGet(opts) {
19
61
  console.log(`instruction:`);
20
62
  console.log(c.instruction);
21
63
  }
22
- if (c.transcript) {
64
+ if (c.transcriptTurns?.length) {
65
+ printTimedTranscript(c.transcriptTurns);
66
+ }
67
+ else if (c.transcript) {
23
68
  console.log(`transcript:`);
24
69
  console.log(c.transcript);
25
70
  }
@@ -0,0 +1,31 @@
1
+ import { lookupNumber } from "../../lib/ops/lookup.js";
2
+ import { isDialError } from "../../lib/ops/errors.js";
3
+ import { printDialError } from "../../lib/cli-error.js";
4
+ /** Channels in the order they are printed, with the label each one shows as. */
5
+ const CHANNEL_LABELS = {
6
+ imessage: "iMessage",
7
+ };
8
+ export async function runLookup(number, opts) {
9
+ try {
10
+ const result = await lookupNumber(number);
11
+ if (opts.json) {
12
+ console.log(JSON.stringify({ ok: true, ...result }));
13
+ return 0;
14
+ }
15
+ console.log(result.number);
16
+ for (const [channel, supported] of Object.entries(result.supports)) {
17
+ // A channel added to the API after this CLI shipped still prints, under its
18
+ // raw key — reporting an unknown channel is better than hiding it.
19
+ const label = CHANNEL_LABELS[channel] ?? channel;
20
+ console.log(` ${label.padEnd(9)} ${supported ? "yes" : "no"}`);
21
+ }
22
+ return 0;
23
+ }
24
+ catch (e) {
25
+ // A lookup that could not be completed exits non-zero rather than printing
26
+ // "no": the two mean different things, and only one is worth retrying.
27
+ if (isDialError(e))
28
+ return printDialError(opts.json, e);
29
+ throw e;
30
+ }
31
+ }
@@ -0,0 +1,20 @@
1
+ import { apiGet } from "../api.js";
2
+ import { maybeAuth } from "./auth.js";
3
+ import { DialError } from "./errors.js";
4
+ /**
5
+ * Look a number up.
6
+ *
7
+ * Shared by `dial lookup` and the local MCP `lookup_number` tool, so both speak to
8
+ * the API through one place and inherit the saved key the same way.
9
+ *
10
+ * A lookup Dial could not complete comes back as an error (502), never as a
11
+ * negative verdict — so a `false` here always means the number genuinely is not
12
+ * reachable on that channel, and callers can treat the two differently.
13
+ */
14
+ export async function lookupNumber(number) {
15
+ const auth = maybeAuth();
16
+ const res = await apiGet(`/api/v1/lookup?number=${encodeURIComponent(number)}`, auth?.apiKey);
17
+ if (!res.ok)
18
+ throw new DialError("lookup_failed", res.error, res.status);
19
+ return res.data;
20
+ }
@@ -147,6 +147,17 @@ export const messageSchema = z
147
147
  createdAt: z.string().optional(),
148
148
  })
149
149
  .passthrough();
150
+ export const transcriptTurnSchema = z
151
+ .object({
152
+ speaker: z
153
+ .enum(["agent", "user", "transfer_target"])
154
+ .describe("`agent` is Dial's AI voice agent, `user` the human on the other end, and " +
155
+ "`transfer_target` the human the call was cold-transferred to"),
156
+ text: z.string().describe("What was said during the turn"),
157
+ startMs: z.number().describe("Approximate ms into the call's audio at which the turn began"),
158
+ endMs: z.number().describe("Approximate ms into the call's audio at which the turn ended"),
159
+ })
160
+ .describe("One uninterrupted stretch of speech by one party, placed in time");
150
161
  export const callSchema = z
151
162
  .object({
152
163
  id: z.string(),
@@ -156,6 +167,13 @@ export const callSchema = z
156
167
  status: statusSchema,
157
168
  duration: z.number().nullish(),
158
169
  transcript: z.string().nullish(),
170
+ transcriptTurns: transcriptTurnSchema
171
+ .array()
172
+ .nullish()
173
+ .describe("The same conversation as `transcript`, split into timed turns and ordered by " +
174
+ "startMs. Use it to measure pacing: the pause before a turn is its startMs minus " +
175
+ "the previous turn's endMs. Null when the call has no transcript, or when the " +
176
+ "call's turn timing was not recorded."),
159
177
  instruction: z.string().nullable().optional(),
160
178
  createdAt: z.string().optional(),
161
179
  })
@@ -9,7 +9,9 @@ export const getCallTool = {
9
9
  name: "get_call",
10
10
  config: {
11
11
  title: "Get Call",
12
- description: "Fetch a single call by id — status, duration, and transcript when available.",
12
+ description: "Fetch a single call by id — status, duration, and transcript when available. " +
13
+ "The transcript comes back twice: `transcript` as flat text, and `transcriptTurns` " +
14
+ "as timed turns for analysing pacing and spotting long pauses.",
13
15
  inputSchema,
14
16
  outputSchema: { call: callSchema },
15
17
  annotations: { readOnlyHint: true, openWorldHint: true },
@@ -8,6 +8,7 @@ import { stopTypingTool } from "./stop-typing.js";
8
8
  import { listMessagesTool } from "./list-messages.js";
9
9
  import { listGroupsTool } from "./list-groups.js";
10
10
  import { listContactsTool } from "./list-contacts.js";
11
+ import { lookupNumberTool } from "./lookup-number.js";
11
12
  import { placeCallTool } from "./place-call.js";
12
13
  import { listCallsTool } from "./list-calls.js";
13
14
  import { getCallTool } from "./get-call.js";
@@ -35,6 +36,7 @@ export const tools = [
35
36
  listMessagesTool,
36
37
  listGroupsTool,
37
38
  listContactsTool,
39
+ lookupNumberTool,
38
40
  placeCallTool,
39
41
  listCallsTool,
40
42
  getCallTool,
@@ -0,0 +1,33 @@
1
+ import { z } from "zod";
2
+ import { jsonResult } from "../result.js";
3
+ import { lookupNumber } from "../../lib/ops/lookup.js";
4
+ const inputSchema = {
5
+ number: z
6
+ .string()
7
+ .describe("The phone number to look up, in E.164 — e.g. +14155550123. Any number, not just your own."),
8
+ };
9
+ export const lookupNumberTool = {
10
+ name: "lookup_number",
11
+ config: {
12
+ title: "Look Up A Number",
13
+ description: "Check what channels a phone number can receive on, before sending to it. Works on ANY number in the " +
14
+ "world — it doesn't have to be one of your numbers, and you don't have to have messaged it before. " +
15
+ "`supports` describes the number you asked about, not your own line: each key is a channel and the " +
16
+ "boolean says whether that number can be reached there. Read the channels you need by name, since more " +
17
+ "are added over time. The answer is point-in-time, not a property of the number — someone who changes " +
18
+ "device or turns the service off stops being reachable — so treat `true` as a strong signal for picking " +
19
+ "a channel rather than a promise that the send will land. A lookup that fails is an error, never a " +
20
+ "`false`, so a `false` always means the number genuinely isn't reachable there.",
21
+ inputSchema,
22
+ outputSchema: {
23
+ number: z.string().describe("The number you asked about, normalized to E.164"),
24
+ supports: z
25
+ .object({
26
+ imessage: z.boolean().describe("Whether the number can currently receive iMessage"),
27
+ })
28
+ .describe("One key per channel, true when the number can be reached there"),
29
+ },
30
+ annotations: { readOnlyHint: true, openWorldHint: true },
31
+ },
32
+ run: async (args) => jsonResult(await lookupNumber(args.number)),
33
+ };
@@ -31,6 +31,7 @@ export const OPERATIONAL_TOOL_NAMES = [
31
31
  "list_messages",
32
32
  "list_groups",
33
33
  "list_contacts",
34
+ "lookup_number",
34
35
  "place_call",
35
36
  "list_calls",
36
37
  "get_call",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getdial/cli",
3
- "version": "0.42.0",
3
+ "version": "0.44.0",
4
4
  "description": "Dial CLI — install, sign up, and run the local listen service.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/skills.tar.gz CHANGED
Binary file