@indemn/cli 1.7.0 → 1.7.2

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 (44) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CONTRIBUTING.md +2 -2
  4. package/dist/mcp/registry.d.ts.map +1 -1
  5. package/dist/mcp/registry.js +6 -4
  6. package/dist/mcp/registry.js.map +1 -1
  7. package/dist/mcp/tool-meta.d.ts.map +1 -1
  8. package/dist/mcp/tool-meta.js +35 -9
  9. package/dist/mcp/tool-meta.js.map +1 -1
  10. package/dist/mcp/tools.d.ts.map +1 -1
  11. package/dist/mcp/tools.js +232 -49
  12. package/dist/mcp/tools.js.map +1 -1
  13. package/dist/report/styles.d.ts +1 -0
  14. package/dist/report/styles.d.ts.map +1 -1
  15. package/dist/report/styles.js +5 -1
  16. package/dist/report/styles.js.map +1 -1
  17. package/dist/sdk/auth.js +2 -2
  18. package/dist/sdk/auth.js.map +1 -1
  19. package/dist/sdk/client.d.ts.map +1 -1
  20. package/dist/sdk/client.js +11 -1
  21. package/dist/sdk/client.js.map +1 -1
  22. package/dist/sdk/config-change.d.ts +4 -0
  23. package/dist/sdk/config-change.d.ts.map +1 -1
  24. package/dist/sdk/config-change.js +27 -5
  25. package/dist/sdk/config-change.js.map +1 -1
  26. package/dist/sdk/kb.d.ts +12 -0
  27. package/dist/sdk/kb.d.ts.map +1 -1
  28. package/dist/sdk/kb.js +45 -5
  29. package/dist/sdk/kb.js.map +1 -1
  30. package/dist/sdk/voice.js +5 -5
  31. package/dist/sdk/voice.js.map +1 -1
  32. package/package.json +4 -6
  33. package/src/mcp/registry.ts +9 -4
  34. package/src/mcp/tool-meta.ts +35 -9
  35. package/src/mcp/tools.ts +235 -49
  36. package/src/report/styles.ts +5 -1
  37. package/src/sdk/auth.ts +2 -2
  38. package/src/sdk/client.ts +5 -1
  39. package/src/sdk/config-change.ts +27 -5
  40. package/src/sdk/kb.ts +49 -8
  41. package/src/sdk/voice.ts +5 -5
  42. package/test/mcp/__snapshots__/tools-snapshot.test.ts.snap +1 -0
  43. package/test/mcp/darwin-fixes.test.ts +231 -0
  44. 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,23 @@ 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
+ typeof args.kb_id === 'string' ? add(args.source_id, null, (id) => kb.findDataSource(String(args.kb_id), id).then((d) => label(d?.question ?? d?.name ?? d?.source_url))) : undefined,
188
+ add(args.ticket_id, null, (id) => tickets.get(id).then((t) => { const x = t; return label([x.ticket_ref, x.title].filter(Boolean).join(' ')); })),
189
+ add(args.rule_id, null, (id) => tickets.listRules({ includeTrashed: true }).then((rules) => label(rules.find((r) => (r._id ?? r.id) === id)?.name))),
190
+ 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); })),
191
+ add(args.template_id, null, (id) => distribution.listTemplates().then((ts) => label(ts.find((t) => t._id === id)?.name))),
192
+ 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); })),
193
+ 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
194
  add(orgId, null, (id) => client.copilotGet(`/organization/${id}`).then((r) => r.organization)),
169
195
  ]);
170
196
  return { names, missing };
@@ -214,6 +240,20 @@ export function registerTools(server, client) {
214
240
  const config = new ConfigSDK(client);
215
241
  const functions = new FunctionsSDK(client);
216
242
  // A skill of an agent by its id.
243
+ /** An export result with a signed download_url: the services hand out unsigned file-store links (403 in a browser). */
244
+ const withDownloadLink = async (result, stored) => {
245
+ if (!stored || typeof stored !== 'string')
246
+ return result;
247
+ const signed = /X-Amz-Signature=/.test(stored) ? stored : await kb.signedLink(stored);
248
+ return { ...result, download_url: signed ?? stored, ...(signed ? {} : { note: 'The link could not be signed; it may need signing in to the dashboard.' }) };
249
+ };
250
+ /** A report file written by this process, put in the organization's file store so the person can download it. */
251
+ const deliver = async (result, upload) => {
252
+ if (!upload)
253
+ return result;
254
+ const { signedUrl, url } = await kb.storeFile(result.file_path);
255
+ return { ...result, download_url: signedUrl ?? url };
256
+ };
217
257
  const skillOf = async (agentId, id) => {
218
258
  const skill = (await functions.listFunctions(agentId)).find((f) => f._id === id);
219
259
  if (!skill)
@@ -240,10 +280,14 @@ export function registerTools(server, client) {
240
280
  // ---------------------------------------------------------------------------
241
281
  // Agent tools
242
282
  // ---------------------------------------------------------------------------
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) => {
283
+ reg.tool('list_agents', 'List the agents (bots) of the organization: id, name, channels. Start here to find an agent id.', {
284
+ channel: z.string().optional().describe('Only agents on this channel, e.g. web, voice, email'),
285
+ search: z.string().optional().describe('Only agents whose name contains this text'),
286
+ }, async (args) => {
244
287
  try {
245
288
  const pages = await orgPages(client);
246
289
  const wanted = args.channel?.trim().toUpperCase();
290
+ const needle = args.search?.trim().toLowerCase();
247
291
  // What a person needs, not the stored record (a page of full records ran to 73k characters).
248
292
  const rows = (await agents.listAgents()).map((a) => ({
249
293
  _id: a._id,
@@ -255,7 +299,8 @@ export function registerTools(server, client) {
255
299
  updated_at: a.updatedAt,
256
300
  url: `${pages}/ai-studio/bot/${a._id}/configurations`,
257
301
  }));
258
- return success(wanted ? rows.filter((a) => a.channels.some((c) => c.toUpperCase() === wanted)) : rows);
302
+ const byChannel = wanted ? rows.filter((a) => a.channels.some((c) => c.toUpperCase() === wanted)) : rows;
303
+ return success(needle ? byChannel.filter((a) => a.name.toLowerCase().includes(needle)) : byChannel);
259
304
  }
260
305
  catch (err) {
261
306
  return error(err);
@@ -363,7 +408,7 @@ export function registerTools(server, client) {
363
408
  preview: async (a) => {
364
409
  const agent = await agents.getAgent(String(a.agent_id));
365
410
  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.`;
411
+ return `This will delete agent "${agent.name}"${mayBeLive(lifecycle) ? `, which ${liveText(lifecycle)}` : ''}. It cannot be restored.`;
367
412
  },
368
413
  });
369
414
  const insights = new AgentInsightsSDK(client);
@@ -410,7 +455,16 @@ export function registerTools(server, client) {
410
455
  channels: z.array(z.string()).optional().describe('Channels of the copy'),
411
456
  }, async (args) => {
412
457
  try {
413
- return success(await agents.cloneAgent(args.agent_id, { name: args.name, channels: args.channels }));
458
+ const copy = await agents.cloneAgent(args.agent_id, { name: args.name, channels: args.channels });
459
+ // copilot's clone answers { new_agent_id, new_agent_name, ... } and leaves the description empty:
460
+ // carry the original's over.
461
+ const copyId = copy._id ?? copy.new_agent_id;
462
+ const source = await agents.getAgent(args.agent_id);
463
+ if (source.description && !copy.description && copyId) {
464
+ const described = await agents.updateAgent(copyId, { description: source.description }).then(() => true).catch(() => false);
465
+ return success({ ...copy, ...(described ? { description: source.description } : {}) });
466
+ }
467
+ return success(copy);
414
468
  }
415
469
  catch (err) {
416
470
  return error(err);
@@ -425,7 +479,7 @@ export function registerTools(server, client) {
425
479
  }
426
480
  });
427
481
  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'),
482
+ bundle: jsonObject().describe('The bundle returned by export_agent (an object, or JSON text)'),
429
483
  name: z.string().optional().describe('Name for the new agent (default: the bundle name)'),
430
484
  link_knowledge_bases: z.boolean().optional().describe('Link the same knowledge base ids if they exist in this org'),
431
485
  allow_duplicate: z.boolean().optional(),
@@ -466,12 +520,14 @@ export function registerTools(server, client) {
466
520
  system_prompt: a.system_prompt,
467
521
  greeting: a.first_message,
468
522
  model: a.model ? modelArg(String(a.model)) : undefined,
523
+ parameters: a.temperature !== undefined ? { temperature: a.temperature } : undefined,
469
524
  });
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).', {
525
+ 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
526
  agent_id: z.string().describe('The agent ID'),
472
527
  system_prompt: z.string().optional().describe('New system prompt (whole text)'),
473
528
  first_message: z.string().optional().describe('New greeting shown to users'),
474
529
  model: z.string().optional().describe('provider/model, e.g. openai/gpt-4.1 (see list_llm_models)'),
530
+ temperature: z.number().min(0).max(2).optional().describe('Model temperature (0 = most predictable); only for models that expose it'),
475
531
  confirm_published: z.boolean().optional().describe('Required with confirm when the agent is published and the user explicitly agreed'),
476
532
  }, async (args, meta) => {
477
533
  try {
@@ -528,9 +584,15 @@ export function registerTools(server, client) {
528
584
  ...skillAuthFields,
529
585
  configuration: jsonObject().optional().describe('Other configurationSchema fields (an object, or JSON text); the fields above win'),
530
586
  transfer_to: z.string().optional().describe('transfer_call: phone number (E.164, e.g. +15551234567) or SIP address (sip:...) calls are transferred to'),
587
+ parameters: jsonArray(z.object({
588
+ name: z.string(), type: z.string().describe('string, number or boolean'),
589
+ description: z.string().optional(), required: z.boolean().optional(),
590
+ allowed_values: z.array(z.string()).optional().describe('Fixed choices the customer picks from'),
591
+ })).optional().describe('What the skill collects from the customer before it runs ({{name}} placeholders in url/payload)'),
531
592
  }, async (args) => {
532
593
  try {
533
594
  const { agent_id, type, name, description } = args;
595
+ const parameters = (args.parameters ?? []);
534
596
  const configurationSchema = skillConfiguration(type, withAuthorization(args));
535
597
  // copilot sends a REST answer straight to the customer unless craftResponse is on; with nothing to say
536
598
  // how to present it, let the agent phrase it.
@@ -549,10 +611,18 @@ export function registerTools(server, client) {
549
611
  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
612
  // copilot turns configurationSchema into the executable form only on update: create, then configure.
551
613
  const created = await functions.createFunction(agent_id, { type, name, description });
614
+ // Parameters are their own records: a skill created with parameters used to lose them silently.
615
+ const addParameters = async () => {
616
+ const saved = [];
617
+ for (const { allowed_values, ...p } of parameters)
618
+ saved.push(await functions.addParameter(agent_id, created._id, { ...p, allowedValues: allowed_values }));
619
+ return saved;
620
+ };
552
621
  if (!configurationSchema)
553
- return success(created);
622
+ return success(parameters.length ? { ...created, parameters: await addParameters() } : created);
554
623
  try {
555
- return success(await functions.updateFunction(agent_id, created._id, { type, description: description ?? '', configurationSchema }));
624
+ const configured = await functions.updateFunction(agent_id, created._id, { type, description: description ?? '', configurationSchema });
625
+ return success(parameters.length ? { ...configured, parameters: await addParameters() } : configured);
556
626
  }
557
627
  catch (err) {
558
628
  // Leave no half-made skill behind: remove the empty one, then say why nothing was saved.
@@ -637,10 +707,10 @@ export function registerTools(server, client) {
637
707
  return error(err);
638
708
  }
639
709
  });
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) => {
710
+ 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
711
  try {
642
712
  const result = await functions.exportFunctions(args.agent_id);
643
- return success(result);
713
+ return success(await withDownloadLink(result, result.signedUrl ?? result.fileUrl ?? result.url));
644
714
  }
645
715
  catch (err) {
646
716
  return error(err);
@@ -723,7 +793,7 @@ export function registerTools(server, client) {
723
793
  catch (err) {
724
794
  return error(err);
725
795
  }
726
- });
796
+ }, liveChange('a skill parameter', ['function_id']));
727
797
  reg.tool('update_function_parameter', 'Change a skill parameter.', {
728
798
  agent_id: z.string().describe('The agent ID'),
729
799
  function_id: z.string().describe('The function ID'),
@@ -742,7 +812,7 @@ export function registerTools(server, client) {
742
812
  catch (err) {
743
813
  return error(err);
744
814
  }
745
- });
815
+ }, liveChange('a skill parameter', ['function_id', 'parameter_id']));
746
816
  reg.tool('delete_function_parameter', 'Remove a skill parameter.', {
747
817
  agent_id: z.string().describe('The agent ID'),
748
818
  function_id: z.string().describe('The function ID'),
@@ -837,10 +907,11 @@ export function registerTools(server, client) {
837
907
  return error(err);
838
908
  }
839
909
  });
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).', {
910
+ 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
911
  kb_id: z.string().describe('The knowledge base ID'),
842
912
  page: z.number().optional().describe('Page number'),
843
913
  limit: z.number().optional().describe('Items per page'),
914
+ full: z.boolean().optional().describe('Include the chunks and statistics of every entry'),
844
915
  }, async (args) => {
845
916
  try {
846
917
  const query = {};
@@ -849,7 +920,16 @@ export function registerTools(server, client) {
849
920
  if (args.limit !== undefined)
850
921
  query.limit = String(args.limit);
851
922
  const result = await kb.listDataSources(args.kb_id, Object.keys(query).length ? query : undefined);
852
- return success(result);
923
+ if (args.full)
924
+ return success(result);
925
+ // The stored record repeats every chunk twice (chunks, chunks_text): a page of it is tens of kilobytes.
926
+ const entries = (result.dataSources ?? []).map((d) => ({
927
+ _id: d._id, type: d.type, question: d.question ?? d.name, answer: d.answer,
928
+ ...(d.source_url ? { source_url: d.source_url } : {}),
929
+ trained: d.trained, ...(d.status ? { status: d.status } : {}),
930
+ ...(d.error_message ? { error: d.error_message } : {}),
931
+ }));
932
+ return success({ ...result, dataSources: entries });
853
933
  }
854
934
  catch (err) {
855
935
  return error(err);
@@ -1003,25 +1083,32 @@ export function registerTools(server, client) {
1003
1083
  return error(err);
1004
1084
  }
1005
1085
  });
1006
- reg.tool('export_kb', 'Export the question/answer pairs of a knowledge base as a CSV or XLSX download link.', {
1086
+ 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
1087
  kb_id: z.string().describe('The knowledge base ID'),
1008
- format: z.string().optional().describe('Export format (e.g. "json", "csv")'),
1088
+ format: z.string().optional().describe('Export format: csv (default) or xlsx'),
1009
1089
  }, async (args) => {
1010
1090
  try {
1011
- const result = await kb.exportKB(args.kb_id, args.format || 'json');
1012
- return success(result);
1091
+ const result = await kb.exportKB(args.kb_id, args.format || 'csv');
1092
+ return success(await withDownloadLink(result, result.signedUrl ?? result.fileUrl));
1013
1093
  }
1014
1094
  catch (err) {
1015
1095
  return error(err);
1016
1096
  }
1017
1097
  });
1018
- reg.tool('import_kb', 'Import websites or files into a knowledge base; processed in the background (see kb_scrape_status).', {
1098
+ 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
1099
  kb_id: z.string().describe('The knowledge base ID'),
1020
- data: z.record(z.unknown()).describe('Data to import'),
1100
+ data: kbImportSchema.describe('{ type: "file", sources: [{ name, file_type, url }] } (an object, or JSON text)'),
1021
1101
  }, async (args) => {
1022
1102
  try {
1023
- const result = await kb.importKB(args.kb_id, args.data);
1024
- return success(result);
1103
+ const data = args.data;
1104
+ // The import service tells the file's kind from file_type: fill it from the name or the link.
1105
+ const sources = data.sources.map((s) => ({
1106
+ ...s,
1107
+ name: s.name ?? s.url.split('?')[0].split('/').pop() ?? 'file',
1108
+ file_type: (s.file_type ?? (s.name ?? s.url.split('?')[0]).split('.').pop() ?? '').toLowerCase(),
1109
+ }));
1110
+ const result = await kb.importKB(args.kb_id, { ...data, sources });
1111
+ return success({ ...result, imported: sources.map((s) => s.name) });
1025
1112
  }
1026
1113
  catch (err) {
1027
1114
  return error(err);
@@ -1067,7 +1154,8 @@ export function registerTools(server, client) {
1067
1154
  return success({
1068
1155
  conversation_id: conversation.request_id, test: conversation.isTestMode === true,
1069
1156
  url: conversationUrl(client, projectId, conversation.request_id),
1070
- messages: messages.map((m) => ({ role: m.role, text: m.text, at: m.createdAt })),
1157
+ // System lines carry the dashboard's template ("{{userLabel}} has ended the call"): say it in words.
1158
+ messages: messages.map((m) => ({ role: m.role, text: (m.text ?? '').replace(/\{\{\s*userLabel\s*\}\}/g, 'The customer'), at: m.createdAt })),
1071
1159
  });
1072
1160
  }
1073
1161
  catch (err) {
@@ -1157,9 +1245,16 @@ export function registerTools(server, client) {
1157
1245
  return error(err);
1158
1246
  }
1159
1247
  });
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) => {
1248
+ 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
1249
  try {
1162
- return success(args.agent_id ? await voice.agentNumbers(args.agent_id) : await voice.orgNumbers());
1250
+ if (args.agent_id)
1251
+ return success(await voice.agentNumbers(args.agent_id));
1252
+ // copilot's org list holds only unattached ("reserve") numbers; the attached ones sit on the agents.
1253
+ const [free, all] = await Promise.all([voice.orgNumbers(), agents.listAgents()]);
1254
+ const attached = all
1255
+ .filter((a) => a.connected_phone)
1256
+ .map((a) => ({ phone_number: a.connected_phone, attached: true, agent_id: a._id, agent_name: a.name }));
1257
+ return success([...attached, ...free.map((n) => ({ phone_number: n.phoneNumber, attached: false, name: n.friendlyName, type: n.type }))]);
1163
1258
  }
1164
1259
  catch (err) {
1165
1260
  return error(err);
@@ -1204,7 +1299,7 @@ export function registerTools(server, client) {
1204
1299
  try {
1205
1300
  // A purchase needs the person, not the model: confirm: true from a host without approval forms is not enough.
1206
1301
  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.');
1302
+ 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
1303
  return success(await voice.buyNumber(args.agent_id, args.phone_number, { country: args.country, tollFree: args.toll_free }));
1209
1304
  }
1210
1305
  catch (err) {
@@ -1365,17 +1460,63 @@ export function registerTools(server, client) {
1365
1460
  return error(err);
1366
1461
  }
1367
1462
  });
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) => {
1463
+ const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
1464
+ const clock = (hhmm) => {
1465
+ const [h, m] = hhmm.split(':').map(Number);
1466
+ const hour = ((h + 11) % 12) + 1;
1467
+ return `${hour}${m ? `:${String(m).padStart(2, '0')}` : ''}${h < 12 ? 'am' : 'pm'}`;
1468
+ };
1469
+ /** What agents say about the hours, built from the hours themselves so it never contradicts them. */
1470
+ const hoursMessage = (days) => {
1471
+ const open = days.filter((d) => d.slots.length).sort((a, b) => a.day - b.day);
1472
+ if (!open.length)
1473
+ return 'We are currently closed.';
1474
+ const parts = open.map((d) => `${DAY_NAMES[d.day]} ${d.slots.map((s) => `${clock(s.start)} to ${clock(s.end)}`).join(' and ')}`);
1475
+ return `We're open ${parts.join('; ')}.`;
1476
+ };
1477
+ 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.', {
1478
+ enabled: z.boolean().optional().describe('Enforce the hours (agents say the out-of-hours message outside them); unchanged when omitted'),
1479
+ 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(),
1480
+ out_of_hours_message: z.string().optional(),
1481
+ }, async (args) => {
1369
1482
  try {
1370
- return success(await orgSettings.setBusinessHours({ businessHoursEnabled: args.enabled, businessHours: args.hours, ...(args.out_of_hours_message ? { businessHoursUtterance: args.out_of_hours_message } : {}) }));
1483
+ const current = (await orgSettings.get());
1484
+ const enabled = args.enabled ?? Boolean(current.businessHoursConfig?.businessHoursEnabled);
1485
+ const given = (args.hours ?? []);
1486
+ // Replace means replace: a day the person did not mention is closed.
1487
+ const days = args.hours ? DAY_NAMES.map((_, day) => ({ day, slots: given.filter((d) => d.day === day).flatMap((d) => d.slots) })) : undefined;
1488
+ return success(await orgSettings.setBusinessHours({
1489
+ businessHoursEnabled: enabled,
1490
+ ...(days ? { businessHours: days, businessHoursUtterance: args.out_of_hours_message ?? hoursMessage(days) } : {}),
1491
+ ...(!days && args.out_of_hours_message ? { businessHoursUtterance: args.out_of_hours_message } : {}),
1492
+ }));
1493
+ }
1494
+ catch (err) {
1495
+ return error(err);
1496
+ }
1497
+ }, { preview: async (a) => {
1498
+ const hours = (a.hours ?? []);
1499
+ const what = a.hours ? hoursMessage(DAY_NAMES.map((_, day) => ({ day, slots: hours.filter((d) => d.day === day).flatMap((d) => d.slots) }))) : 'the hours unchanged';
1500
+ 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.'}`;
1501
+ } });
1502
+ reg.tool('add_holiday', 'Close the org on a date.', {
1503
+ date: z.string().describe('YYYY-MM-DD'), name: z.string(),
1504
+ message: z.string().optional().describe('What agents say that day (default: closed on account of the holiday)'),
1505
+ type: z.string().optional().describe('public or custom (default custom)'),
1506
+ }, async (args) => {
1507
+ try {
1508
+ return success(await orgSettings.addHolidays([{
1509
+ name: args.name, date: args.date, type: args.type ?? 'custom',
1510
+ holidayFallbackUtterance: args.message ?? `Our offices are currently closed on account of ${args.name}.`,
1511
+ }]));
1371
1512
  }
1372
1513
  catch (err) {
1373
1514
  return error(err);
1374
1515
  }
1375
1516
  });
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) => {
1517
+ 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
1518
  try {
1378
- return success(await orgSettings.addHolidays([{ name: args.name, date: args.date, ...(args.message ? { holidayFallbackUtterance: args.message } : {}) }]));
1519
+ return success(await orgSettings.removeHolidays([args.date]));
1379
1520
  }
1380
1521
  catch (err) {
1381
1522
  return error(err);
@@ -1409,7 +1550,14 @@ export function registerTools(server, client) {
1409
1550
  }, { preview: async (a) => `This will ${(await orgSettings.planAdmin(a)).changes.join('; ')}.` });
1410
1551
  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
1552
  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 }));
1553
+ const entries = await new AuditSDK(client).list({ since: args.since, action: args.action, resourceType: args.resource_type, user: args.user_id, limit: args.limit });
1554
+ // copilot's log carries the person's id only: name them from the member list (one read).
1555
+ const members = entries.some((e) => e.id_user) ? await orgs.listMembers().catch(() => []) : [];
1556
+ const byId = new Map(members.map((m) => [m.user_id, m]));
1557
+ return success(entries.map((e) => {
1558
+ const m = byId.get(String(e.id_user ?? ''));
1559
+ return m ? { ...e, user_name: m.name, user_email: m.email } : e;
1560
+ }));
1413
1561
  }
1414
1562
  catch (err) {
1415
1563
  return error(err);
@@ -1450,10 +1598,12 @@ export function registerTools(server, client) {
1450
1598
  return error(err);
1451
1599
  }
1452
1600
  });
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(),
1601
+ 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.', {
1602
+ ticket_id: z.string(), title: z.string().optional(), description: z.string().optional(),
1603
+ notes: z.string().optional().describe('Replace the whole notes text'),
1455
1604
  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(),
1605
+ assignee_user_id: z.string().optional(), unassign: z.boolean().optional(), resolve: z.boolean().optional(),
1606
+ note: z.string().optional().describe('A note to add: appended to the ticket\'s notes, or the closing note when resolving'),
1457
1607
  link_ticket_id: z.string().optional().describe('Link to another ticket'),
1458
1608
  }, async (args) => {
1459
1609
  try {
@@ -1462,6 +1612,11 @@ export function registerTools(server, client) {
1462
1612
  for (const k of ['title', 'description', 'notes', 'status', 'priority'])
1463
1613
  if (args[k] !== undefined)
1464
1614
  changes[k] = args[k];
1615
+ // A note outside resolve used to be dropped silently: it goes onto the ticket's notes.
1616
+ if (args.note && !args.resolve) {
1617
+ const existing = String((await tickets.get(args.ticket_id)).notes ?? '').trim();
1618
+ changes.notes = args.notes !== undefined ? `${args.notes}\n${args.note}`.trim() : existing ? `${existing}\n${args.note}` : args.note;
1619
+ }
1465
1620
  if (Object.keys(changes).length)
1466
1621
  out.updated = await tickets.update(args.ticket_id, changes);
1467
1622
  if (args.assignee_user_id || args.unassign)
@@ -1573,7 +1728,7 @@ export function registerTools(server, client) {
1573
1728
  return error(err);
1574
1729
  }
1575
1730
  });
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) => {
1731
+ 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
1732
  try {
1578
1733
  return success(args.id ? await platform.update(args.resource, args.id, args.body) : await platform.create(args.resource, args.body));
1579
1734
  }
@@ -1758,7 +1913,7 @@ export function registerTools(server, client) {
1758
1913
  });
1759
1914
  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
1915
  run_id: z.string(),
1761
- min_pass_rate: z.number().min(0).max(1).optional(),
1916
+ min_pass_rate: z.number().min(0).max(1).optional().describe('Pass rate the run must reach (default 0.8)'),
1762
1917
  max_p95_ms: z.number().optional(),
1763
1918
  error_policy: z.enum(['block', 'warn']).optional(),
1764
1919
  fail_on_error: z.boolean().optional(),
@@ -1767,7 +1922,9 @@ export function registerTools(server, client) {
1767
1922
  const run = await evals.getRunInOrg(args.run_id);
1768
1923
  if (run.status !== 'completed' && run.status !== 'failed')
1769
1924
  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 }) });
1925
+ // Without a threshold any finished run "passed" (a 1-of-3 run was reported as a pass): judge against 80%.
1926
+ const minPassRate = args.min_pass_rate ?? DEFAULT_MIN_PASS_RATE;
1927
+ 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
1928
  }
1772
1929
  catch (err) {
1773
1930
  return error(err);
@@ -1862,19 +2019,41 @@ export function registerTools(server, client) {
1862
2019
  return error(err);
1863
2020
  }
1864
2021
  });
1865
- reg.tool('evaluate_transcript', 'Grade a replayed conversation against a baseline with the agent\'s stored evaluator (paid model calls).', {
2022
+ // get_conversation says "agent"; the evaluations service says "bot". Both are accepted.
2023
+ const transcriptTurn = z.object({ sender: z.enum(['user', 'bot', 'agent', 'system']), text: z.string() });
2024
+ const transcriptForService = (turns) => turns
2025
+ .filter((t) => t.sender !== 'system')
2026
+ .map((t) => ({ sender: (t.sender === 'user' ? 'user' : 'bot'), text: t.text }));
2027
+ const transcriptPreconditions = async (a) => {
2028
+ const agentId = String(a.agent_id);
2029
+ await evals.assertAgentInOrg(agentId);
2030
+ const configs = await evals.listEvaluatorConfigs(agentId);
2031
+ const count = Array.isArray(configs) ? configs.length : (configs.items ?? []).length;
2032
+ if (!count)
2033
+ 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).');
2034
+ if (JSON.stringify(a.baseline_transcript) === JSON.stringify(a.replay_transcript)) {
2035
+ 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.');
2036
+ }
2037
+ };
2038
+ 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
2039
  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() })),
2040
+ baseline_transcript: jsonArray(transcriptTurn),
2041
+ replay_transcript: jsonArray(transcriptTurn),
1869
2042
  }, async (args) => {
1870
2043
  try {
1871
- await evals.assertAgentInOrg(args.agent_id);
1872
- return success(await evals.evaluateTranscript(args));
2044
+ await transcriptPreconditions(args);
2045
+ return success(await evals.evaluateTranscript({
2046
+ agent_id: args.agent_id,
2047
+ baseline_transcript: transcriptForService(args.baseline_transcript),
2048
+ replay_transcript: transcriptForService(args.replay_transcript),
2049
+ }));
1873
2050
  }
1874
2051
  catch (err) {
1875
2052
  return error(err);
1876
2053
  }
1877
- });
2054
+ },
2055
+ // Checked before the approval card: a card for something that cannot run wastes the person's answer.
2056
+ { preview: async (a) => { await transcriptPreconditions(a); return `This will grade a replayed transcript for agent ${a.agent_id} with paid model calls.`; } });
1878
2057
  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
2058
  try {
1880
2059
  await evals.assertAgentInOrg(args.agent_id);
@@ -2060,13 +2239,15 @@ export function registerTools(server, client) {
2060
2239
  // ---------------------------------------------------------------------------
2061
2240
  // Export tools
2062
2241
  // ---------------------------------------------------------------------------
2242
+ 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
2243
  reg.tool('export_eval_markdown', 'Export evaluation results as a structured markdown file with conversations, scores, and criteria', {
2064
2244
  run_id: z.string().describe('The evaluation run ID'),
2065
2245
  output_path: z.string().optional().describe('Output file path (defaults to current directory)'),
2246
+ upload: uploadArg,
2066
2247
  }, async (args) => {
2067
2248
  try {
2068
2249
  const result = await exportEvalMarkdown(args.run_id, args.output_path, client);
2069
- return success(result);
2250
+ return success(await deliver(result, args.upload));
2070
2251
  }
2071
2252
  catch (err) {
2072
2253
  return error(err);
@@ -2075,10 +2256,11 @@ export function registerTools(server, client) {
2075
2256
  reg.tool('export_eval_report', 'Generate a branded PDF evaluation report with cover page, scores, and per-item details', {
2076
2257
  run_id: z.string().describe('The evaluation run ID'),
2077
2258
  output_path: z.string().optional().describe('Output file path (defaults to current directory)'),
2259
+ upload: uploadArg,
2078
2260
  }, async (args) => {
2079
2261
  try {
2080
2262
  const result = await exportEvalReport(args.run_id, args.output_path, client);
2081
- return success(result);
2263
+ return success(await deliver(result, args.upload));
2082
2264
  }
2083
2265
  catch (err) {
2084
2266
  return error(err);
@@ -2087,10 +2269,11 @@ export function registerTools(server, client) {
2087
2269
  reg.tool('export_agent_card', 'Generate a branded PDF agent card with system prompt, tools, KBs, LLM config, and evaluation history', {
2088
2270
  agent_id: z.string().describe('The agent ID'),
2089
2271
  output_path: z.string().optional().describe('Output file path (defaults to current directory)'),
2272
+ upload: uploadArg,
2090
2273
  }, async (args) => {
2091
2274
  try {
2092
2275
  const result = await exportAgentCard(args.agent_id, args.output_path, client);
2093
- return success(result);
2276
+ return success(await deliver(result, args.upload));
2094
2277
  }
2095
2278
  catch (err) {
2096
2279
  return error(err);