@indemn/cli 1.7.1 → 1.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.
Files changed (40) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/mcp/registry.d.ts.map +1 -1
  4. package/dist/mcp/registry.js +6 -4
  5. package/dist/mcp/registry.js.map +1 -1
  6. package/dist/mcp/tool-meta.d.ts.map +1 -1
  7. package/dist/mcp/tool-meta.js +35 -9
  8. package/dist/mcp/tool-meta.js.map +1 -1
  9. package/dist/mcp/tools.d.ts.map +1 -1
  10. package/dist/mcp/tools.js +235 -49
  11. package/dist/mcp/tools.js.map +1 -1
  12. package/dist/report/styles.d.ts +1 -0
  13. package/dist/report/styles.d.ts.map +1 -1
  14. package/dist/report/styles.js +5 -1
  15. package/dist/report/styles.js.map +1 -1
  16. package/dist/sdk/client.d.ts.map +1 -1
  17. package/dist/sdk/client.js +11 -1
  18. package/dist/sdk/client.js.map +1 -1
  19. package/dist/sdk/config-change.d.ts +4 -0
  20. package/dist/sdk/config-change.d.ts.map +1 -1
  21. package/dist/sdk/config-change.js +27 -5
  22. package/dist/sdk/config-change.js.map +1 -1
  23. package/dist/sdk/kb.d.ts +13 -0
  24. package/dist/sdk/kb.d.ts.map +1 -1
  25. package/dist/sdk/kb.js +48 -6
  26. package/dist/sdk/kb.js.map +1 -1
  27. package/dist/sdk/voice.js +5 -5
  28. package/dist/sdk/voice.js.map +1 -1
  29. package/package.json +2 -2
  30. package/src/mcp/registry.ts +9 -4
  31. package/src/mcp/tool-meta.ts +35 -9
  32. package/src/mcp/tools.ts +238 -49
  33. package/src/report/styles.ts +5 -1
  34. package/src/sdk/client.ts +5 -1
  35. package/src/sdk/config-change.ts +27 -5
  36. package/src/sdk/kb.ts +53 -10
  37. package/src/sdk/voice.ts +5 -5
  38. package/test/mcp/__snapshots__/tools-snapshot.test.ts.snap +1 -0
  39. package/test/mcp/darwin-fixes.test.ts +249 -0
  40. package/test/mcp/tools-snapshot.test.ts +1 -1
package/dist/mcp/tools.js CHANGED
@@ -47,9 +47,11 @@ function withAuthorization(args) {
47
47
  }
48
48
  return { ...args, configuration: { ...(args.configuration ?? {}), authorization } };
49
49
  }
50
- // Helper: format a successful MCP tool result
50
+ /** The CI gate's threshold when the caller gives none (evaluations repo scripts/eval_gate.py uses the same). */
51
+ const DEFAULT_MIN_PASS_RATE = 0.8;
52
+ // Helper: format a successful MCP tool result (the registry adds the context and formats it)
51
53
  function success(data) {
52
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
54
+ return { content: [{ type: 'text', text: JSON.stringify(data) }] };
53
55
  }
54
56
  // Helper: format an error MCP tool result
55
57
  function error(err) {
@@ -121,6 +123,19 @@ const parseJsonText = (v) => {
121
123
  }
122
124
  };
123
125
  const jsonObject = () => z.preprocess(parseJsonText, z.record(z.any()));
126
+ // A list a model may also write as JSON text.
127
+ const jsonArray = (item) => z.preprocess(parseJsonText, z.array(item));
128
+ // What the knowledge base import accepts: typed, so a wrong shape is refused here instead of being
129
+ // "imported" as nothing (copilot answers success for any body).
130
+ const kbImportSchema = z.preprocess(parseJsonText, z.object({
131
+ type: z.enum(['file']).describe('Kind of import; files from the organization\'s file store'),
132
+ sources: z.array(z.object({
133
+ name: z.string().optional().describe('File name shown in the knowledge base'),
134
+ file_type: z.string().optional().describe('Extension, e.g. pdf, txt, docx (taken from the name when missing)'),
135
+ url: z.string().describe('The file\'s link in the file store'),
136
+ }).passthrough()).min(1),
137
+ is_qna_import: z.boolean().optional().describe('The file holds question/answer pairs'),
138
+ }).passthrough());
124
139
  // A skill for bulk_create_skills with flat fields (the configuration is built as create_function builds it);
125
140
  // a proposal from generate_skills_from_doc keeps its own configurationSchema.
126
141
  const skillDefinition = z.preprocess(parseJsonText, z.object({
@@ -159,12 +174,26 @@ export function registerTools(server, client) {
159
174
  }, (err) => { if (what && notFound.test(err instanceof Error ? err.message : String(err)))
160
175
  missing.push(`${what} ${id}`); })
161
176
  : undefined;
177
+ const agentId = typeof args.agent_id === 'string' ? args.agent_id : undefined;
178
+ const label = (name) => ({ name: typeof name === 'string' && name ? name : undefined });
162
179
  await Promise.all([
163
180
  add(args.agent_id ?? args.bot_id, 'agent', (id) => agents.getAgent(id)),
164
- typeof args.agent_id === 'string' ? add(args.function_id, 'skill', (id) => skillOf(String(args.agent_id), id)) : undefined,
181
+ agentId ? add(args.function_id, 'skill', (id) => skillOf(agentId, id)) : undefined,
165
182
  add(args.kb_id, 'knowledge base', (id) => kb.getKB(id)),
183
+ ...(Array.isArray(args.kb_ids) ? args.kb_ids : []).map((id) => add(id, 'knowledge base', (x) => kb.getKB(x))),
166
184
  add(args.test_set_id, 'test set', (id) => testSets.getTestSet(id)),
167
185
  add(args.rubric_id, 'rubric', (id) => rubrics.getRubric(id)),
186
+ // The rest name things the cards used to show as raw ids.
187
+ // Q&A entries come from the entry list; files and pages only from the knowledge base's sources.
188
+ typeof args.kb_id === 'string' ? add(args.source_id, null, (id) => kb.findDataSource(String(args.kb_id), id).then((d) => d
189
+ ? label(d.question ?? d.name ?? d.source_url)
190
+ : kb.scrapeStatus(String(args.kb_id)).then((all) => { const s = all.find((x) => x.id === id); return label(s?.name ?? s?.source); }))) : undefined,
191
+ add(args.ticket_id, null, (id) => tickets.get(id).then((t) => { const x = t; return label([x.ticket_ref, x.title].filter(Boolean).join(' ')); })),
192
+ add(args.rule_id, null, (id) => tickets.listRules({ includeTrashed: true }).then((rules) => label(rules.find((r) => (r._id ?? r.id) === id)?.name))),
193
+ add(args.distribution_id, null, (id) => distribution.list().then((d) => { const p = (d.paths ?? []).find((x) => x.id === id); return label(p ? `${p.path_name ?? ''} (${p.host_name ?? '*'}${p.path_expression ?? ''})`.trim() : undefined); })),
194
+ add(args.template_id, null, (id) => distribution.listTemplates().then((ts) => label(ts.find((t) => t._id === id)?.name))),
195
+ add(args.owner_id, null, (id) => orgs.listMembers().then((ms) => { const m = ms.find((x) => x.user_id === id); return label(m?.name ?? m?.email); })),
196
+ agentId && typeof args.function_id === 'string' ? add(args.parameter_id, null, (id) => skillOf(agentId, String(args.function_id)).then((s) => functions.listParameters(agentId, s._id)).then((ps) => label(ps.find((p) => p._id === id)?.name))) : undefined,
168
197
  add(orgId, null, (id) => client.copilotGet(`/organization/${id}`).then((r) => r.organization)),
169
198
  ]);
170
199
  return { names, missing };
@@ -214,6 +243,20 @@ export function registerTools(server, client) {
214
243
  const config = new ConfigSDK(client);
215
244
  const functions = new FunctionsSDK(client);
216
245
  // A skill of an agent by its id.
246
+ /** An export result with a signed download_url: the services hand out unsigned file-store links (403 in a browser). */
247
+ const withDownloadLink = async (result, stored) => {
248
+ if (!stored || typeof stored !== 'string')
249
+ return result;
250
+ const signed = /X-Amz-Signature=/.test(stored) ? stored : await kb.signedLink(stored);
251
+ return { ...result, download_url: signed ?? stored, ...(signed ? {} : { note: 'The link could not be signed; it may need signing in to the dashboard.' }) };
252
+ };
253
+ /** A report file written by this process, put in the organization's file store so the person can download it. */
254
+ const deliver = async (result, upload) => {
255
+ if (!upload)
256
+ return result;
257
+ const { signedUrl, url } = await kb.storeFile(result.file_path);
258
+ return { ...result, download_url: signedUrl ?? url };
259
+ };
217
260
  const skillOf = async (agentId, id) => {
218
261
  const skill = (await functions.listFunctions(agentId)).find((f) => f._id === id);
219
262
  if (!skill)
@@ -240,10 +283,14 @@ export function registerTools(server, client) {
240
283
  // ---------------------------------------------------------------------------
241
284
  // Agent tools
242
285
  // ---------------------------------------------------------------------------
243
- reg.tool('list_agents', 'List the agents (bots) of the organization: id, name, channels. Start here to find an agent id.', { channel: z.string().optional().describe('Only agents on this channel, e.g. web, voice, email') }, async (args) => {
286
+ reg.tool('list_agents', 'List the agents (bots) of the organization: id, name, channels. Start here to find an agent id.', {
287
+ channel: z.string().optional().describe('Only agents on this channel, e.g. web, voice, email'),
288
+ search: z.string().optional().describe('Only agents whose name contains this text'),
289
+ }, async (args) => {
244
290
  try {
245
291
  const pages = await orgPages(client);
246
292
  const wanted = args.channel?.trim().toUpperCase();
293
+ const needle = args.search?.trim().toLowerCase();
247
294
  // What a person needs, not the stored record (a page of full records ran to 73k characters).
248
295
  const rows = (await agents.listAgents()).map((a) => ({
249
296
  _id: a._id,
@@ -255,7 +302,8 @@ export function registerTools(server, client) {
255
302
  updated_at: a.updatedAt,
256
303
  url: `${pages}/ai-studio/bot/${a._id}/configurations`,
257
304
  }));
258
- return success(wanted ? rows.filter((a) => a.channels.some((c) => c.toUpperCase() === wanted)) : rows);
305
+ const byChannel = wanted ? rows.filter((a) => a.channels.some((c) => c.toUpperCase() === wanted)) : rows;
306
+ return success(needle ? byChannel.filter((a) => a.name.toLowerCase().includes(needle)) : byChannel);
259
307
  }
260
308
  catch (err) {
261
309
  return error(err);
@@ -363,7 +411,7 @@ export function registerTools(server, client) {
363
411
  preview: async (a) => {
364
412
  const agent = await agents.getAgent(String(a.agent_id));
365
413
  const lifecycle = await lifecycleOf(String(a.agent_id));
366
- return `This will delete agent "${agent.name}"${mayBeLive(lifecycle) ? `, which ${liveText(lifecycle)}` : ''}. It cannot be restored from the CLI.`;
414
+ return `This will delete agent "${agent.name}"${mayBeLive(lifecycle) ? `, which ${liveText(lifecycle)}` : ''}. It cannot be restored.`;
367
415
  },
368
416
  });
369
417
  const insights = new AgentInsightsSDK(client);
@@ -410,7 +458,16 @@ export function registerTools(server, client) {
410
458
  channels: z.array(z.string()).optional().describe('Channels of the copy'),
411
459
  }, async (args) => {
412
460
  try {
413
- return success(await agents.cloneAgent(args.agent_id, { name: args.name, channels: args.channels }));
461
+ const copy = await agents.cloneAgent(args.agent_id, { name: args.name, channels: args.channels });
462
+ // copilot's clone answers { new_agent_id, new_agent_name, ... } and leaves the description empty:
463
+ // carry the original's over.
464
+ const copyId = copy._id ?? copy.new_agent_id;
465
+ const source = await agents.getAgent(args.agent_id);
466
+ if (source.description && !copy.description && copyId) {
467
+ const described = await agents.updateAgent(copyId, { description: source.description }).then(() => true).catch(() => false);
468
+ return success({ ...copy, ...(described ? { description: source.description } : {}) });
469
+ }
470
+ return success(copy);
414
471
  }
415
472
  catch (err) {
416
473
  return error(err);
@@ -425,7 +482,7 @@ export function registerTools(server, client) {
425
482
  }
426
483
  });
427
484
  reg.tool('import_agent', 'Create a new agent in this org from a bundle made by export_agent (possibly from another org or platform). Lists skills that failed and secrets to fill in.', {
428
- bundle: z.record(z.unknown()).describe('The bundle returned by export_agent'),
485
+ bundle: jsonObject().describe('The bundle returned by export_agent (an object, or JSON text)'),
429
486
  name: z.string().optional().describe('Name for the new agent (default: the bundle name)'),
430
487
  link_knowledge_bases: z.boolean().optional().describe('Link the same knowledge base ids if they exist in this org'),
431
488
  allow_duplicate: z.boolean().optional(),
@@ -466,12 +523,14 @@ export function registerTools(server, client) {
466
523
  system_prompt: a.system_prompt,
467
524
  greeting: a.first_message,
468
525
  model: a.model ? modelArg(String(a.model)) : undefined,
526
+ parameters: a.temperature !== undefined ? { temperature: a.temperature } : undefined,
469
527
  });
470
- reg.tool('set_config', 'Change an agent\'s prompt, greeting and/or model. The confirmation shows the prompt diff and whether the agent is published; published agents need confirm_published too. The result says what was saved (read back from the platform).', {
528
+ reg.tool('set_config', 'Change an agent\'s prompt, greeting, model and/or temperature. The confirmation shows the prompt diff and whether the agent is published; published agents need confirm_published too. The result says what was saved (read back from the platform).', {
471
529
  agent_id: z.string().describe('The agent ID'),
472
530
  system_prompt: z.string().optional().describe('New system prompt (whole text)'),
473
531
  first_message: z.string().optional().describe('New greeting shown to users'),
474
532
  model: z.string().optional().describe('provider/model, e.g. openai/gpt-4.1 (see list_llm_models)'),
533
+ temperature: z.number().min(0).max(2).optional().describe('Model temperature (0 = most predictable); only for models that expose it'),
475
534
  confirm_published: z.boolean().optional().describe('Required with confirm when the agent is published and the user explicitly agreed'),
476
535
  }, async (args, meta) => {
477
536
  try {
@@ -528,9 +587,15 @@ export function registerTools(server, client) {
528
587
  ...skillAuthFields,
529
588
  configuration: jsonObject().optional().describe('Other configurationSchema fields (an object, or JSON text); the fields above win'),
530
589
  transfer_to: z.string().optional().describe('transfer_call: phone number (E.164, e.g. +15551234567) or SIP address (sip:...) calls are transferred to'),
590
+ parameters: jsonArray(z.object({
591
+ name: z.string(), type: z.string().describe('string, number or boolean'),
592
+ description: z.string().optional(), required: z.boolean().optional(),
593
+ allowed_values: z.array(z.string()).optional().describe('Fixed choices the customer picks from'),
594
+ })).optional().describe('What the skill collects from the customer before it runs ({{name}} placeholders in url/payload)'),
531
595
  }, async (args) => {
532
596
  try {
533
597
  const { agent_id, type, name, description } = args;
598
+ const parameters = (args.parameters ?? []);
534
599
  const configurationSchema = skillConfiguration(type, withAuthorization(args));
535
600
  // copilot sends a REST answer straight to the customer unless craftResponse is on; with nothing to say
536
601
  // how to present it, let the agent phrase it.
@@ -549,10 +614,18 @@ export function registerTools(server, client) {
549
614
  throw new Error(`The agent already has a skill named "${name}" (function_id ${same._id}). Change it with update_function instead of creating another.`);
550
615
  // copilot turns configurationSchema into the executable form only on update: create, then configure.
551
616
  const created = await functions.createFunction(agent_id, { type, name, description });
617
+ // Parameters are their own records: a skill created with parameters used to lose them silently.
618
+ const addParameters = async () => {
619
+ const saved = [];
620
+ for (const { allowed_values, ...p } of parameters)
621
+ saved.push(await functions.addParameter(agent_id, created._id, { ...p, allowedValues: allowed_values }));
622
+ return saved;
623
+ };
552
624
  if (!configurationSchema)
553
- return success(created);
625
+ return success(parameters.length ? { ...created, parameters: await addParameters() } : created);
554
626
  try {
555
- return success(await functions.updateFunction(agent_id, created._id, { type, description: description ?? '', configurationSchema }));
627
+ const configured = await functions.updateFunction(agent_id, created._id, { type, description: description ?? '', configurationSchema });
628
+ return success(parameters.length ? { ...configured, parameters: await addParameters() } : configured);
556
629
  }
557
630
  catch (err) {
558
631
  // Leave no half-made skill behind: remove the empty one, then say why nothing was saved.
@@ -637,10 +710,10 @@ export function registerTools(server, client) {
637
710
  return error(err);
638
711
  }
639
712
  });
640
- reg.tool('export_functions', 'Export every skill of an agent as a JSON file (to copy them or keep a backup before import_functions).', { agent_id: z.string().describe('The agent ID') }, async (args) => {
713
+ reg.tool('export_functions', 'Export every skill of an agent as a JSON file (to copy them or keep a backup before import_functions); download_url is the link to give the person.', { agent_id: z.string().describe('The agent ID') }, async (args) => {
641
714
  try {
642
715
  const result = await functions.exportFunctions(args.agent_id);
643
- return success(result);
716
+ return success(await withDownloadLink(result, result.signedUrl ?? result.fileUrl ?? result.url));
644
717
  }
645
718
  catch (err) {
646
719
  return error(err);
@@ -723,7 +796,7 @@ export function registerTools(server, client) {
723
796
  catch (err) {
724
797
  return error(err);
725
798
  }
726
- });
799
+ }, liveChange('a skill parameter', ['function_id']));
727
800
  reg.tool('update_function_parameter', 'Change a skill parameter.', {
728
801
  agent_id: z.string().describe('The agent ID'),
729
802
  function_id: z.string().describe('The function ID'),
@@ -742,7 +815,7 @@ export function registerTools(server, client) {
742
815
  catch (err) {
743
816
  return error(err);
744
817
  }
745
- });
818
+ }, liveChange('a skill parameter', ['function_id', 'parameter_id']));
746
819
  reg.tool('delete_function_parameter', 'Remove a skill parameter.', {
747
820
  agent_id: z.string().describe('The agent ID'),
748
821
  function_id: z.string().describe('The function ID'),
@@ -837,10 +910,11 @@ export function registerTools(server, client) {
837
910
  return error(err);
838
911
  }
839
912
  });
840
- reg.tool('list_kb_data', 'The question/answer pairs typed into a knowledge base (website and file sources are listed by get_knowledge_base).', {
913
+ reg.tool('list_kb_data', 'The question/answer pairs typed into a knowledge base (website and file sources are listed by get_knowledge_base). Each entry: id, question, answer, whether it is trained. full: true adds the stored chunks.', {
841
914
  kb_id: z.string().describe('The knowledge base ID'),
842
915
  page: z.number().optional().describe('Page number'),
843
916
  limit: z.number().optional().describe('Items per page'),
917
+ full: z.boolean().optional().describe('Include the chunks and statistics of every entry'),
844
918
  }, async (args) => {
845
919
  try {
846
920
  const query = {};
@@ -849,7 +923,16 @@ export function registerTools(server, client) {
849
923
  if (args.limit !== undefined)
850
924
  query.limit = String(args.limit);
851
925
  const result = await kb.listDataSources(args.kb_id, Object.keys(query).length ? query : undefined);
852
- return success(result);
926
+ if (args.full)
927
+ return success(result);
928
+ // The stored record repeats every chunk twice (chunks, chunks_text): a page of it is tens of kilobytes.
929
+ const entries = (result.dataSources ?? []).map((d) => ({
930
+ _id: d._id, type: d.type, question: d.question ?? d.name, answer: d.answer,
931
+ ...(d.source_url ? { source_url: d.source_url } : {}),
932
+ trained: d.trained, ...(d.status ? { status: d.status } : {}),
933
+ ...(d.error_message ? { error: d.error_message } : {}),
934
+ }));
935
+ return success({ ...result, dataSources: entries });
853
936
  }
854
937
  catch (err) {
855
938
  return error(err);
@@ -1003,25 +1086,32 @@ export function registerTools(server, client) {
1003
1086
  return error(err);
1004
1087
  }
1005
1088
  });
1006
- reg.tool('export_kb', 'Export the question/answer pairs of a knowledge base as a CSV or XLSX download link.', {
1089
+ reg.tool('export_kb', 'Export the question/answer pairs of a knowledge base as a CSV or XLSX file; download_url is the link to give the person (valid for a while).', {
1007
1090
  kb_id: z.string().describe('The knowledge base ID'),
1008
- format: z.string().optional().describe('Export format (e.g. "json", "csv")'),
1091
+ format: z.string().optional().describe('Export format: csv (default) or xlsx'),
1009
1092
  }, async (args) => {
1010
1093
  try {
1011
- const result = await kb.exportKB(args.kb_id, args.format || 'json');
1012
- return success(result);
1094
+ const result = await kb.exportKB(args.kb_id, args.format || 'csv');
1095
+ return success(await withDownloadLink(result, result.signedUrl ?? result.fileUrl));
1013
1096
  }
1014
1097
  catch (err) {
1015
1098
  return error(err);
1016
1099
  }
1017
1100
  });
1018
- reg.tool('import_kb', 'Import websites or files into a knowledge base; processed in the background (see kb_scrape_status).', {
1101
+ reg.tool('import_kb', 'Import files (already in the organization\'s file store) into a knowledge base; processed in the background (see kb_scrape_status). For a website use scrape_url.', {
1019
1102
  kb_id: z.string().describe('The knowledge base ID'),
1020
- data: z.record(z.unknown()).describe('Data to import'),
1103
+ data: kbImportSchema.describe('{ type: "file", sources: [{ name, file_type, url }] } (an object, or JSON text)'),
1021
1104
  }, async (args) => {
1022
1105
  try {
1023
- const result = await kb.importKB(args.kb_id, args.data);
1024
- return success(result);
1106
+ const data = args.data;
1107
+ // The import service tells the file's kind from file_type: fill it from the name or the link.
1108
+ const sources = data.sources.map((s) => ({
1109
+ ...s,
1110
+ name: s.name ?? s.url.split('?')[0].split('/').pop() ?? 'file',
1111
+ file_type: (s.file_type ?? (s.name ?? s.url.split('?')[0]).split('.').pop() ?? '').toLowerCase(),
1112
+ }));
1113
+ const result = await kb.importKB(args.kb_id, { ...data, sources });
1114
+ return success({ ...result, imported: sources.map((s) => s.name) });
1025
1115
  }
1026
1116
  catch (err) {
1027
1117
  return error(err);
@@ -1067,7 +1157,8 @@ export function registerTools(server, client) {
1067
1157
  return success({
1068
1158
  conversation_id: conversation.request_id, test: conversation.isTestMode === true,
1069
1159
  url: conversationUrl(client, projectId, conversation.request_id),
1070
- messages: messages.map((m) => ({ role: m.role, text: m.text, at: m.createdAt })),
1160
+ // System lines carry the dashboard's template ("{{userLabel}} has ended the call"): say it in words.
1161
+ messages: messages.map((m) => ({ role: m.role, text: (m.text ?? '').replace(/\{\{\s*userLabel\s*\}\}/g, 'The customer'), at: m.createdAt })),
1071
1162
  });
1072
1163
  }
1073
1164
  catch (err) {
@@ -1157,9 +1248,16 @@ export function registerTools(server, client) {
1157
1248
  return error(err);
1158
1249
  }
1159
1250
  });
1160
- reg.tool('list_agent_numbers', 'Phone numbers attached to an agent, or (without agent_id) every number the org owns.', { agent_id: z.string().optional() }, async (args) => {
1251
+ reg.tool('list_agent_numbers', 'Phone numbers attached to an agent, or (without agent_id) every number the org owns: the ones attached to an agent (with the agent) and the free ones.', { agent_id: z.string().optional() }, async (args) => {
1161
1252
  try {
1162
- return success(args.agent_id ? await voice.agentNumbers(args.agent_id) : await voice.orgNumbers());
1253
+ if (args.agent_id)
1254
+ return success(await voice.agentNumbers(args.agent_id));
1255
+ // copilot's org list holds only unattached ("reserve") numbers; the attached ones sit on the agents.
1256
+ const [free, all] = await Promise.all([voice.orgNumbers(), agents.listAgents()]);
1257
+ const attached = all
1258
+ .filter((a) => a.connected_phone)
1259
+ .map((a) => ({ phone_number: a.connected_phone, attached: true, agent_id: a._id, agent_name: a.name }));
1260
+ return success([...attached, ...free.map((n) => ({ phone_number: n.phoneNumber, attached: false, name: n.friendlyName, type: n.type }))]);
1163
1261
  }
1164
1262
  catch (err) {
1165
1263
  return error(err);
@@ -1204,7 +1302,7 @@ export function registerTools(server, client) {
1204
1302
  try {
1205
1303
  // A purchase needs the person, not the model: confirm: true from a host without approval forms is not enough.
1206
1304
  if (!meta.confirmedByPerson)
1207
- throw new Error('Buying a number needs a person to approve it in an approval form, and this host cannot show one. Nothing was bought. Buy it in the dashboard, or with `indemn agents numbers buy` in a terminal.');
1305
+ throw new Error('Buying a number needs a person to approve it in an approval form, and this host cannot show one. Nothing was bought. Buy it in the dashboard instead.');
1208
1306
  return success(await voice.buyNumber(args.agent_id, args.phone_number, { country: args.country, tollFree: args.toll_free }));
1209
1307
  }
1210
1308
  catch (err) {
@@ -1365,17 +1463,63 @@ export function registerTools(server, client) {
1365
1463
  return error(err);
1366
1464
  }
1367
1465
  });
1368
- reg.tool('set_business_hours', 'Replace the org business hours. days: 0 Sunday to 6; an empty slots list closes that day.', { enabled: z.boolean(), hours: z.array(z.object({ day: z.number().int().min(0).max(6), slots: z.array(z.object({ start: z.string().describe('HH:MM'), end: z.string().describe('HH:MM') })) })).optional(), out_of_hours_message: z.string().optional() }, async (args) => {
1466
+ const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
1467
+ const clock = (hhmm) => {
1468
+ const [h, m] = hhmm.split(':').map(Number);
1469
+ const hour = ((h + 11) % 12) + 1;
1470
+ return `${hour}${m ? `:${String(m).padStart(2, '0')}` : ''}${h < 12 ? 'am' : 'pm'}`;
1471
+ };
1472
+ /** What agents say about the hours, built from the hours themselves so it never contradicts them. */
1473
+ const hoursMessage = (days) => {
1474
+ const open = days.filter((d) => d.slots.length).sort((a, b) => a.day - b.day);
1475
+ if (!open.length)
1476
+ return 'We are currently closed.';
1477
+ const parts = open.map((d) => `${DAY_NAMES[d.day]} ${d.slots.map((s) => `${clock(s.start)} to ${clock(s.end)}`).join(' and ')}`);
1478
+ return `We're open ${parts.join('; ')}.`;
1479
+ };
1480
+ reg.tool('set_business_hours', 'Replace the org business hours: every day not listed is closed. days: 0 Sunday to 6. enabled turns enforcement on or off (unchanged when omitted). The out-of-hours message is rebuilt from the hours unless given.', {
1481
+ enabled: z.boolean().optional().describe('Enforce the hours (agents say the out-of-hours message outside them); unchanged when omitted'),
1482
+ hours: jsonArray(z.object({ day: z.number().int().min(0).max(6), slots: z.array(z.object({ start: z.string().describe('HH:MM'), end: z.string().describe('HH:MM') })) })).optional(),
1483
+ out_of_hours_message: z.string().optional(),
1484
+ }, async (args) => {
1369
1485
  try {
1370
- return success(await orgSettings.setBusinessHours({ businessHoursEnabled: args.enabled, businessHours: args.hours, ...(args.out_of_hours_message ? { businessHoursUtterance: args.out_of_hours_message } : {}) }));
1486
+ const current = (await orgSettings.get());
1487
+ const enabled = args.enabled ?? Boolean(current.businessHoursConfig?.businessHoursEnabled);
1488
+ const given = (args.hours ?? []);
1489
+ // Replace means replace: a day the person did not mention is closed.
1490
+ const days = args.hours ? DAY_NAMES.map((_, day) => ({ day, slots: given.filter((d) => d.day === day).flatMap((d) => d.slots) })) : undefined;
1491
+ return success(await orgSettings.setBusinessHours({
1492
+ businessHoursEnabled: enabled,
1493
+ ...(days ? { businessHours: days, businessHoursUtterance: args.out_of_hours_message ?? hoursMessage(days) } : {}),
1494
+ ...(!days && args.out_of_hours_message ? { businessHoursUtterance: args.out_of_hours_message } : {}),
1495
+ }));
1496
+ }
1497
+ catch (err) {
1498
+ return error(err);
1499
+ }
1500
+ }, { preview: async (a) => {
1501
+ const hours = (a.hours ?? []);
1502
+ const what = a.hours ? hoursMessage(DAY_NAMES.map((_, day) => ({ day, slots: hours.filter((d) => d.day === day).flatMap((d) => d.slots) }))) : 'the hours unchanged';
1503
+ return `This will replace the org business hours: ${what}${a.enabled === undefined ? '' : a.enabled ? ' Enforced: agents say the out-of-hours message outside them.' : ' Not enforced.'}`;
1504
+ } });
1505
+ reg.tool('add_holiday', 'Close the org on a date.', {
1506
+ date: z.string().describe('YYYY-MM-DD'), name: z.string(),
1507
+ message: z.string().optional().describe('What agents say that day (default: closed on account of the holiday)'),
1508
+ type: z.string().optional().describe('public or custom (default custom)'),
1509
+ }, async (args) => {
1510
+ try {
1511
+ return success(await orgSettings.addHolidays([{
1512
+ name: args.name, date: args.date, type: args.type ?? 'custom',
1513
+ holidayFallbackUtterance: args.message ?? `Our offices are currently closed on account of ${args.name}.`,
1514
+ }]));
1371
1515
  }
1372
1516
  catch (err) {
1373
1517
  return error(err);
1374
1518
  }
1375
1519
  });
1376
- reg.tool('add_holiday', 'Close the org on a date.', { date: z.string().describe('YYYY-MM-DD'), name: z.string(), message: z.string().optional().describe('What agents say that day') }, async (args) => {
1520
+ reg.tool('remove_holiday', 'Open the org again on a date that was a holiday.', { date: z.string().describe('YYYY-MM-DD, as get_org_settings lists it') }, async (args) => {
1377
1521
  try {
1378
- return success(await orgSettings.addHolidays([{ name: args.name, date: args.date, ...(args.message ? { holidayFallbackUtterance: args.message } : {}) }]));
1522
+ return success(await orgSettings.removeHolidays([args.date]));
1379
1523
  }
1380
1524
  catch (err) {
1381
1525
  return error(err);
@@ -1409,7 +1553,14 @@ export function registerTools(server, client) {
1409
1553
  }, { preview: async (a) => `This will ${(await orgSettings.planAdmin(a)).changes.join('; ')}.` });
1410
1554
  reg.tool('list_audit_logs', 'Who changed what in the org, newest first, filtered here (the server has no filters).', { since: z.string().optional(), action: z.string().optional(), resource_type: z.string().optional(), user_id: z.string().optional(), limit: z.number().int().min(1).max(500).optional() }, async (args) => {
1411
1555
  try {
1412
- return success(await new AuditSDK(client).list({ since: args.since, action: args.action, resourceType: args.resource_type, user: args.user_id, limit: args.limit }));
1556
+ const entries = await new AuditSDK(client).list({ since: args.since, action: args.action, resourceType: args.resource_type, user: args.user_id, limit: args.limit });
1557
+ // copilot's log carries the person's id only: name them from the member list (one read).
1558
+ const members = entries.some((e) => e.id_user) ? await orgs.listMembers().catch(() => []) : [];
1559
+ const byId = new Map(members.map((m) => [m.user_id, m]));
1560
+ return success(entries.map((e) => {
1561
+ const m = byId.get(String(e.id_user ?? ''));
1562
+ return m ? { ...e, user_name: m.name, user_email: m.email } : e;
1563
+ }));
1413
1564
  }
1414
1565
  catch (err) {
1415
1566
  return error(err);
@@ -1450,10 +1601,12 @@ export function registerTools(server, client) {
1450
1601
  return error(err);
1451
1602
  }
1452
1603
  });
1453
- reg.tool('update_ticket', 'Change a ticket: fields, assignee (user id, or unassign: true), or close it with resolve: true (and an optional note).', {
1454
- ticket_id: z.string(), title: z.string().optional(), description: z.string().optional(), notes: z.string().optional(),
1604
+ reg.tool('update_ticket', 'Change a ticket: fields, a note (added to its notes, or the closing note with resolve), assignee (user id, or unassign: true), or close it with resolve: true.', {
1605
+ ticket_id: z.string(), title: z.string().optional(), description: z.string().optional(),
1606
+ notes: z.string().optional().describe('Replace the whole notes text'),
1455
1607
  status: z.enum(['not_started', 'in_progress']).optional(), priority: ticketPriority.optional(),
1456
- assignee_user_id: z.string().optional(), unassign: z.boolean().optional(), resolve: z.boolean().optional(), note: z.string().optional(),
1608
+ assignee_user_id: z.string().optional(), unassign: z.boolean().optional(), resolve: z.boolean().optional(),
1609
+ note: z.string().optional().describe('A note to add: appended to the ticket\'s notes, or the closing note when resolving'),
1457
1610
  link_ticket_id: z.string().optional().describe('Link to another ticket'),
1458
1611
  }, async (args) => {
1459
1612
  try {
@@ -1462,6 +1615,11 @@ export function registerTools(server, client) {
1462
1615
  for (const k of ['title', 'description', 'notes', 'status', 'priority'])
1463
1616
  if (args[k] !== undefined)
1464
1617
  changes[k] = args[k];
1618
+ // A note outside resolve used to be dropped silently: it goes onto the ticket's notes.
1619
+ if (args.note && !args.resolve) {
1620
+ const existing = String((await tickets.get(args.ticket_id)).notes ?? '').trim();
1621
+ changes.notes = args.notes !== undefined ? `${args.notes}\n${args.note}`.trim() : existing ? `${existing}\n${args.note}` : args.note;
1622
+ }
1465
1623
  if (Object.keys(changes).length)
1466
1624
  out.updated = await tickets.update(args.ticket_id, changes);
1467
1625
  if (args.assignee_user_id || args.unassign)
@@ -1573,7 +1731,7 @@ export function registerTools(server, client) {
1573
1731
  return error(err);
1574
1732
  }
1575
1733
  });
1576
- reg.tool('platform_save', 'Create (no id) or change (id) a V2 agent, template, connector binding or scenario. Components and connectors are read-only shared catalogs.', { resource: z.enum(['agents', 'templates', 'connector-bindings', 'scenarios']), id: z.string().optional(), body: z.record(z.unknown()) }, async (args) => {
1734
+ reg.tool('platform_save', 'Create (no id) or change (id) a V2 agent, template, connector binding or scenario. Components and connectors are read-only shared catalogs.', { resource: z.enum(['agents', 'templates', 'connector-bindings', 'scenarios']), id: z.string().optional(), body: jsonObject().describe('The item to save (an object, or JSON text)') }, async (args) => {
1577
1735
  try {
1578
1736
  return success(args.id ? await platform.update(args.resource, args.id, args.body) : await platform.create(args.resource, args.body));
1579
1737
  }
@@ -1758,7 +1916,7 @@ export function registerTools(server, client) {
1758
1916
  });
1759
1917
  reg.tool('eval_gate_verdict', 'Judge a finished run like the CI gate: pass rate = passed/total, optional p95 limit, failed runs fail by default.', {
1760
1918
  run_id: z.string(),
1761
- min_pass_rate: z.number().min(0).max(1).optional(),
1919
+ min_pass_rate: z.number().min(0).max(1).optional().describe('Pass rate the run must reach (default 0.8)'),
1762
1920
  max_p95_ms: z.number().optional(),
1763
1921
  error_policy: z.enum(['block', 'warn']).optional(),
1764
1922
  fail_on_error: z.boolean().optional(),
@@ -1767,7 +1925,9 @@ export function registerTools(server, client) {
1767
1925
  const run = await evals.getRunInOrg(args.run_id);
1768
1926
  if (run.status !== 'completed' && run.status !== 'failed')
1769
1927
  return success({ finished: false, status: run.status });
1770
- return success({ finished: true, ...gateVerdict(run, { minPassRate: args.min_pass_rate, maxP95Ms: args.max_p95_ms, errorPolicy: args.error_policy, failOnError: args.fail_on_error }) });
1928
+ // Without a threshold any finished run "passed" (a 1-of-3 run was reported as a pass): judge against 80%.
1929
+ const minPassRate = args.min_pass_rate ?? DEFAULT_MIN_PASS_RATE;
1930
+ return success({ finished: true, min_pass_rate: minPassRate, ...gateVerdict(run, { minPassRate, maxP95Ms: args.max_p95_ms, errorPolicy: args.error_policy, failOnError: args.fail_on_error }) });
1771
1931
  }
1772
1932
  catch (err) {
1773
1933
  return error(err);
@@ -1862,19 +2022,41 @@ export function registerTools(server, client) {
1862
2022
  return error(err);
1863
2023
  }
1864
2024
  });
1865
- reg.tool('evaluate_transcript', 'Grade a replayed conversation against a baseline with the agent\'s stored evaluator (paid model calls).', {
2025
+ // get_conversation says "agent"; the evaluations service says "bot". Both are accepted.
2026
+ const transcriptTurn = z.object({ sender: z.enum(['user', 'bot', 'agent', 'system']), text: z.string() });
2027
+ const transcriptForService = (turns) => turns
2028
+ .filter((t) => t.sender !== 'system')
2029
+ .map((t) => ({ sender: (t.sender === 'user' ? 'user' : 'bot'), text: t.text }));
2030
+ const transcriptPreconditions = async (a) => {
2031
+ const agentId = String(a.agent_id);
2032
+ await evals.assertAgentInOrg(agentId);
2033
+ const configs = await evals.listEvaluatorConfigs(agentId);
2034
+ const count = Array.isArray(configs) ? configs.length : (configs.items ?? []).length;
2035
+ if (!count)
2036
+ throw new Error('This agent has no evaluator yet, so a transcript cannot be graded. Create a rubric and run an evaluation first (run_evaluation stores the evaluator).');
2037
+ if (JSON.stringify(a.baseline_transcript) === JSON.stringify(a.replay_transcript)) {
2038
+ throw new Error('The baseline and the replay are the same conversation; grading compares a replay with its baseline. Give the replayed conversation as replay_transcript.');
2039
+ }
2040
+ };
2041
+ reg.tool('evaluate_transcript', 'Grade a replayed conversation against a baseline with the agent\'s stored evaluator (paid model calls). The agent needs an evaluator (a finished run), and the replay must differ from the baseline.', {
1866
2042
  agent_id: z.string(),
1867
- baseline_transcript: z.array(z.object({ sender: z.enum(['user', 'bot']), text: z.string() })),
1868
- replay_transcript: z.array(z.object({ sender: z.enum(['user', 'bot']), text: z.string() })),
2043
+ baseline_transcript: jsonArray(transcriptTurn),
2044
+ replay_transcript: jsonArray(transcriptTurn),
1869
2045
  }, async (args) => {
1870
2046
  try {
1871
- await evals.assertAgentInOrg(args.agent_id);
1872
- return success(await evals.evaluateTranscript(args));
2047
+ await transcriptPreconditions(args);
2048
+ return success(await evals.evaluateTranscript({
2049
+ agent_id: args.agent_id,
2050
+ baseline_transcript: transcriptForService(args.baseline_transcript),
2051
+ replay_transcript: transcriptForService(args.replay_transcript),
2052
+ }));
1873
2053
  }
1874
2054
  catch (err) {
1875
2055
  return error(err);
1876
2056
  }
1877
- });
2057
+ },
2058
+ // Checked before the approval card: a card for something that cannot run wastes the person's answer.
2059
+ { preview: async (a) => { await transcriptPreconditions(a); return `This will grade a replayed transcript for agent ${a.agent_id} with paid model calls.`; } });
1878
2060
  reg.tool('list_evaluator_configs', 'Stored evaluator configs of an agent (which judges and rubric a run uses).', { agent_id: z.string() }, async (args) => {
1879
2061
  try {
1880
2062
  await evals.assertAgentInOrg(args.agent_id);
@@ -2060,13 +2242,15 @@ export function registerTools(server, client) {
2060
2242
  // ---------------------------------------------------------------------------
2061
2243
  // Export tools
2062
2244
  // ---------------------------------------------------------------------------
2245
+ const uploadArg = z.boolean().optional().describe('Put the file in the organization\'s file store and return download_url (use it when the person cannot reach this machine\'s files)');
2063
2246
  reg.tool('export_eval_markdown', 'Export evaluation results as a structured markdown file with conversations, scores, and criteria', {
2064
2247
  run_id: z.string().describe('The evaluation run ID'),
2065
2248
  output_path: z.string().optional().describe('Output file path (defaults to current directory)'),
2249
+ upload: uploadArg,
2066
2250
  }, async (args) => {
2067
2251
  try {
2068
2252
  const result = await exportEvalMarkdown(args.run_id, args.output_path, client);
2069
- return success(result);
2253
+ return success(await deliver(result, args.upload));
2070
2254
  }
2071
2255
  catch (err) {
2072
2256
  return error(err);
@@ -2075,10 +2259,11 @@ export function registerTools(server, client) {
2075
2259
  reg.tool('export_eval_report', 'Generate a branded PDF evaluation report with cover page, scores, and per-item details', {
2076
2260
  run_id: z.string().describe('The evaluation run ID'),
2077
2261
  output_path: z.string().optional().describe('Output file path (defaults to current directory)'),
2262
+ upload: uploadArg,
2078
2263
  }, async (args) => {
2079
2264
  try {
2080
2265
  const result = await exportEvalReport(args.run_id, args.output_path, client);
2081
- return success(result);
2266
+ return success(await deliver(result, args.upload));
2082
2267
  }
2083
2268
  catch (err) {
2084
2269
  return error(err);
@@ -2087,10 +2272,11 @@ export function registerTools(server, client) {
2087
2272
  reg.tool('export_agent_card', 'Generate a branded PDF agent card with system prompt, tools, KBs, LLM config, and evaluation history', {
2088
2273
  agent_id: z.string().describe('The agent ID'),
2089
2274
  output_path: z.string().optional().describe('Output file path (defaults to current directory)'),
2275
+ upload: uploadArg,
2090
2276
  }, async (args) => {
2091
2277
  try {
2092
2278
  const result = await exportAgentCard(args.agent_id, args.output_path, client);
2093
- return success(result);
2279
+ return success(await deliver(result, args.upload));
2094
2280
  }
2095
2281
  catch (err) {
2096
2282
  return error(err);