@velaro/mcp-server 2.0.1 → 2.0.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.
package/fetch-retry.js ADDED
@@ -0,0 +1,23 @@
1
+ // 429-aware fetch for the customer MCP (mcp/server.js). Same behavior as fetchRetry429 in
2
+ // mcp/log-search.js, which is staff-only and not published (log-search.js must stay byte-identical
3
+ // with cli/lib/log-search.js, so this small helper is a separate file rather than an import of it).
4
+
5
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
6
+
7
+ /**
8
+ * fetch that retries ONLY 429 (honouring Retry-After, seconds, capped at 30 s) for up to `retries` attempts and returns the final Response.
9
+ * `init` may be a function so each attempt gets a fresh AbortSignal.
10
+ */
11
+ export async function fetchRetry429(url, init = {}, { retries = 3, fetchImpl = fetch, sleepImpl = sleep } = {}) {
12
+ for (let attempt = 0; ; attempt++) {
13
+ const res = await fetchImpl(url, typeof init === 'function' ? init() : init);
14
+ if (res.status === 429 && attempt < retries) {
15
+ // Number(null) is 0, which would turn a 429 without Retry-After into an immediate retry: a missing header means the 5 s default.
16
+ const raw = res.headers?.get?.('retry-after');
17
+ const ra = raw == null || raw === '' ? NaN : Number(raw);
18
+ await sleepImpl(Math.min(Number.isFinite(ra) && ra >= 0 ? ra : 5, 30) * 1000);
19
+ continue;
20
+ }
21
+ return res;
22
+ }
23
+ }
package/messaging-base.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Resolves the velaro-messaging API base for the MCP server.
2
2
  //
3
3
  // Incident 2026-10-23: the staging MCP was configured with VELARO_ADMIN_API only. Every tool that
4
- // lives in velaro-messaging (magento_diagnose, salesforce_diagnose, sap_diagnose, ...) either threw
4
+ // lives in velaro-messaging (the integration diagnose tools, ...) either threw
5
5
  // "VELARO_MESSAGING_API is not configured" or, on older installs, called the ADMIN host and got a
6
6
  // bare 404 for /diagnostics/test-magento. The messaging base is fully determined by which admin
7
7
  // environment this process talks to, so derive it instead of requiring a second env var.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velaro/mcp-server",
3
- "version": "2.0.1",
3
+ "version": "2.0.3",
4
4
  "description": "Velaro MCP server — connect Claude and other AI agents directly to your Velaro account: KB, workflows, bots, conversations, contacts, routing, and more.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -43,11 +43,12 @@
43
43
  "files": [
44
44
  "server.js",
45
45
  "tool-annotations.js",
46
- "log-search.js",
46
+ "fetch-retry.js",
47
47
  "messaging-base.js",
48
48
  "parity/loader.js",
49
49
  "parity/*.customer.js",
50
50
  "workflow-schedule.js",
51
+ "report-explain.json",
51
52
  "server.json",
52
53
  "README.md"
53
54
  ],
@@ -1,4 +1,4 @@
1
- // velaro activity: integration activity logs and settings audit trail for the authenticated site. The CLI --site staff mode is the existing staff tool support_integration_logs.
1
+ // velaro activity: integration activity logs and settings audit trail for the authenticated site. The CLI --site mode is staff-only and is not part of this package.
2
2
  // Parity module (CLI group -> MCP tools).
3
3
 
4
4
  const enc = encodeURIComponent;
@@ -0,0 +1,232 @@
1
+ {
2
+ "experience-impact": {
3
+ "name": "Experience Impact",
4
+ "answers": "How staffing and adherence affect what customers feel, as a 0 to 100 Experience Index.",
5
+ "sources": "Work Schedules and presence, chat queue timing, missed chats, and CSAT from ticket replies and 1-5 survey ratings (questions tagged csat or star_rating).",
6
+ "prerequisites": "Work Schedules for agents; a survey with a 1-5 CSAT or star question attached to post-chat or a workflow, or ticket CSAT requests turned on; chat traffic in range.",
7
+ "interpret": "Weights: service level 30, adherence 25, CSAT 25, missed chats 20; a signal with no data drops out. CSAT needs 5+ responses to count. Response rate = rated survey submissions / all survey submissions. Actual agents below scheduled while missed chats rise = staffing gap.",
8
+ "whyZero": "CSAT empty: no CSAT survey configured; survey exists but nobody answered; answers came in with no 1-5 rating question; fewer than 5 responses; or the range predates the survey. Surveyed but unrated? Managers run the CSAT backfill (survey ratings only, not ticket CSAT): preview, then apply, via the report banner, `velaro report-backfill csat`, or MCP report_backfill. Adherence empty: no Work Schedules.",
9
+ "fix": "Add a 1-5 star or CSAT question to the post-chat survey, enable ticket CSAT requests, widen the range, then fix the weakest component.",
10
+ "related": "Schedule Adherence, Service Level, Missed Chats, Surveys & NPS, Sentiment, Agent Performance"
11
+ },
12
+ "conversations": {
13
+ "name": "Conversations",
14
+ "answers": "How many conversations came in, on which channels and when.",
15
+ "sources": "Every conversation record on your account, grouped by channel, team, bot or agent, and time.",
16
+ "prerequisites": "At least one deployed channel (chat widget, SMS, email, voice, social).",
17
+ "interpret": "Overview shows totals and trend; Breakdown splits by channel, team and handler. Look at peaks by hour and day to plan staffing. Rising volume with flat staffing predicts longer waits.",
18
+ "whyZero": "No channel deployed or no traffic; the date range or team filter is too narrow; or you lack access to that team's data.",
19
+ "fix": "Widen the range, clear filters, confirm the deployment is live and the widget loads on your site.",
20
+ "related": "Service Level, Missed Chats, Visitor Traffic"
21
+ },
22
+ "missed": {
23
+ "name": "Missed Chats",
24
+ "answers": "Visitors who asked for help and got no live answer, and why.",
25
+ "sources": "Chat requests that ended without an agent or bot response, plus offline or unanswered conversations.",
26
+ "prerequisites": "Live chat deployed with agents or routing configured.",
27
+ "interpret": "Overview gives the missed count and rate. Breakdown shows time of day and team. Analysis groups likely causes such as nobody online, queue timeout, or visitor left. A missed spike at one hour points to a coverage gap.",
28
+ "whyZero": "Genuinely none, no chat traffic, or a bot answers everything so nothing reaches an agent queue.",
29
+ "fix": "Add agents or schedules for peak hours, review routing and after-hours behavior, enable offline message capture.",
30
+ "related": "Service Level, Experience Impact, Agent Availability"
31
+ },
32
+ "agent-performance": {
33
+ "name": "Agent Performance",
34
+ "answers": "How each agent handles conversations: volume, handle time, response speed and quality.",
35
+ "sources": "Conversations assigned to and handled by each agent, message timestamps, and survey scores where collected.",
36
+ "prerequisites": "Agents added to the account and handling chats.",
37
+ "interpret": "Compare an agent against the team over several weeks, not one day. Pair handle time with CSAT: fast and unhappy is a quality problem, slow and happy may be complex work.",
38
+ "whyZero": "Agent has no assigned chats in range, was not online, or the filter excludes their team. Quality scores need survey data.",
39
+ "fix": "Check routing assigns chats to that agent and that they set themselves available.",
40
+ "related": "Response Time, Agent Utilization, Surveys & NPS"
41
+ },
42
+ "response-time": {
43
+ "name": "Response Time",
44
+ "answers": "How quickly customers get a first reply and how long replies take afterwards.",
45
+ "sources": "Message timestamps between the customer's message and the first agent or bot reply.",
46
+ "prerequisites": "Chats with at least one reply.",
47
+ "interpret": "Look at the median and the slow tail together. A fine average with a long tail means some customers wait far too long.",
48
+ "whyZero": "No replies in range, or chats were only bot-handled and excluded from agent timing.",
49
+ "fix": "Add coverage at slow hours, turn on auto-greeting, review the routing capacity limit.",
50
+ "related": "Service Level, Agent Performance"
51
+ },
52
+ "service-level": {
53
+ "name": "Service Level",
54
+ "answers": "The share of chats answered within your target time.",
55
+ "sources": "Queue entry and first-answer timestamps compared with your configured service level threshold.",
56
+ "prerequisites": "A service level threshold in report settings and chats that enter an agent queue.",
57
+ "interpret": "A common target is 80 percent answered in 30 seconds. Check the trend by hour to find breaches.",
58
+ "whyZero": "No chats were queued for agents in the range, or no threshold is set.",
59
+ "fix": "Set the threshold, then staff to the hours that breach it.",
60
+ "related": "Response Time, Missed Chats, Experience Impact"
61
+ },
62
+ "sentiment": {
63
+ "name": "Sentiment",
64
+ "answers": "The overall tone of customer messages, by contact and by agent.",
65
+ "sources": "AI sentiment scoring of conversation messages.",
66
+ "prerequisites": "Sentiment reporting enabled on your plan and enough conversations to score.",
67
+ "interpret": "Watch the trend and the share of negative conversations. Use By Agent for coaching, By Contact to spot at-risk customers. Sentiment is inferred from text, so treat single scores as hints.",
68
+ "whyZero": "Feature not enabled, conversations too short to score, or scoring has not run yet for recent chats.",
69
+ "fix": "Confirm the plan includes it, widen the range, check again after the next processing cycle.",
70
+ "related": "Customer Health, Surveys & NPS, Experience Impact"
71
+ },
72
+ "health": {
73
+ "name": "Customer Health",
74
+ "answers": "Which contacts look healthy or at risk based on recent interactions.",
75
+ "sources": "Conversation frequency, sentiment, unresolved issues and survey results per contact.",
76
+ "prerequisites": "Contacts with conversation history.",
77
+ "interpret": "Low scores with rising contact frequency are the first to act on. Scores are relative to your own customers.",
78
+ "whyZero": "New account with little history, or sentiment data is not yet available.",
79
+ "fix": "Allow history to accumulate and enable surveys to add a direct signal.",
80
+ "related": "Sentiment, Surveys & NPS, Intelligence Suite"
81
+ },
82
+ "surveys": {
83
+ "name": "Surveys & NPS",
84
+ "answers": "What customers say in surveys: CSAT, NPS and custom questions, and how many respond.",
85
+ "sources": "Survey answers collected after chats, through workflows, standalone links or email.",
86
+ "prerequisites": "At least one active survey (post-chat CSAT, NPS or custom) delivered on a channel.",
87
+ "interpret": "Read the average with the response count and response rate. NPS is promoters minus detractors. Under about 30 responses, trends are noisy.",
88
+ "whyZero": "No survey enabled, survey not attached to the channel or workflow, nobody responded, or the range predates the survey. The visitor may close the chat before the survey shows.",
89
+ "fix": "Enable a short post-chat survey on every staffed channel, test it yourself, and keep questions to one or two.",
90
+ "related": "Experience Impact, Sentiment, Missed Chats"
91
+ },
92
+ "tickets": {
93
+ "name": "Tickets",
94
+ "answers": "Ticket volume, status, age and resolution speed.",
95
+ "sources": "Tickets created from email, forms, chats and manual entry.",
96
+ "prerequisites": "Ticketing or an email inbox connected.",
97
+ "interpret": "Watch open backlog and time to resolve. A growing backlog with steady intake means capacity is short.",
98
+ "whyZero": "Ticketing not enabled, no inbox connected, or no tickets in range.",
99
+ "fix": "Connect an inbox or enable ticket creation from forms and chat.",
100
+ "related": "Conversations, Agent Performance"
101
+ },
102
+ "calls": {
103
+ "name": "Calls",
104
+ "answers": "Phone call volume, duration, answered and missed calls.",
105
+ "sources": "Call records from your connected phone line or voice integration.",
106
+ "prerequisites": "A provisioned phone line or connected voice integration.",
107
+ "interpret": "Compare answered against missed by hour. Long durations may signal complexity or unresolved issues.",
108
+ "whyZero": "No phone line connected or no calls in range.",
109
+ "fix": "Connect a number and confirm it routes into Velaro.",
110
+ "related": "IVR, Voice"
111
+ },
112
+ "ivr": {
113
+ "name": "IVR",
114
+ "answers": "How callers move through your phone menus and where they drop off.",
115
+ "sources": "IVR call events for each menu step and outcome.",
116
+ "prerequisites": "An IVR configured on a phone line.",
117
+ "interpret": "A high drop-off at one step suggests a confusing prompt. Many transfers to an agent from one option shows demand to automate.",
118
+ "whyZero": "No IVR configured or no calls reached it.",
119
+ "fix": "Complete IVR setup, place a test call, then re-check.",
120
+ "related": "Calls, Voice"
121
+ },
122
+ "account-utilization": {
123
+ "name": "Account Utilization",
124
+ "answers": "How much of your plan (agents, bots, AI usage, channels) you are using.",
125
+ "sources": "Plan limits compared with measured usage on your account.",
126
+ "prerequisites": "Administrator or manager access.",
127
+ "interpret": "Near 100 percent means you may need a higher limit; very low usage means features you pay for are idle.",
128
+ "whyZero": "Feature not enabled yet or no usage in range.",
129
+ "fix": "Turn on unused features or request a limit increase.",
130
+ "related": "Agent Utilization"
131
+ },
132
+ "agent-availability": {
133
+ "name": "Agent Availability",
134
+ "answers": "When agents were online, away or offline.",
135
+ "sources": "Agent presence and status changes.",
136
+ "prerequisites": "Agents logging in to the workspace.",
137
+ "interpret": "Compare online time with scheduled time and chat demand to find coverage gaps.",
138
+ "whyZero": "Agents did not log in or the selected agents are not in range.",
139
+ "fix": "Confirm agents sign in and set their status to available.",
140
+ "related": "Schedule Adherence, Agent Utilization, Missed Chats"
141
+ },
142
+ "agent-utilization": {
143
+ "name": "Agent Utilization",
144
+ "answers": "How busy agents are compared with their capacity.",
145
+ "sources": "Concurrent chat load and online time per agent.",
146
+ "prerequisites": "Administrator or manager access; agents handling chats.",
147
+ "interpret": "Around 60 to 80 percent is healthy. Above that risks long waits and burnout, well below means spare capacity.",
148
+ "whyZero": "Agents were not online or not given chats.",
149
+ "fix": "Rebalance routing and capacity limits.",
150
+ "related": "Agent Availability, Agent Performance"
151
+ },
152
+ "visitor-traffic": {
153
+ "name": "Visitor Traffic",
154
+ "answers": "Who is visiting pages that have the Velaro widget and how many start chats.",
155
+ "sources": "Visitor tracking from the widget script on your site.",
156
+ "prerequisites": "Visitor tracking enabled and the widget installed on your pages.",
157
+ "interpret": "Compare visits with chats started to find pages where engagement is low.",
158
+ "whyZero": "Widget not installed on those pages, tracking not enabled, or an ad blocker or consent setting blocks it.",
159
+ "fix": "Verify the embed code on the live page and the tracking setting.",
160
+ "related": "Conversations"
161
+ },
162
+ "schedule-adherence": {
163
+ "name": "Schedule Adherence",
164
+ "answers": "Whether agents were online when scheduled.",
165
+ "sources": "Work Schedules compared with agent presence, plus approved exceptions.",
166
+ "prerequisites": "Work Schedules assigned to agents; the site time zone set correctly.",
167
+ "interpret": "Adherence is minutes available during scheduled time divided by scheduled minutes. Approved exceptions are excluded. Use Intraday for now and Timeline for the day's detail.",
168
+ "whyZero": "No schedules assigned, agents not scheduled in range, or time zone mismatch.",
169
+ "fix": "Create and assign schedules and check the time zone.",
170
+ "related": "Experience Impact, Agent Availability, Service Level"
171
+ },
172
+ "chat-intelligence": {
173
+ "name": "Chat Intelligence",
174
+ "answers": "What topics customers ask about, trends, and where the bot or KB falls short.",
175
+ "sources": "AI topic grouping of conversations and missed topics.",
176
+ "prerequisites": "Enough conversations (typically dozens) and the feature enabled.",
177
+ "interpret": "Rising themes show new demand. Missed topics are KB gaps: write or ingest content for them. Recommendations can be applied or dismissed.",
178
+ "whyZero": "Too little traffic, feature not enabled, or analysis has not run.",
179
+ "fix": "Widen the range and allow the next analysis run.",
180
+ "related": "Intelligence Suite, Conversation Efficiency"
181
+ },
182
+ "conversation-efficiency": {
183
+ "name": "Conversation Efficiency",
184
+ "answers": "How efficiently conversations are resolved and where time is lost.",
185
+ "sources": "Message counts, handle time, transfers and outcomes.",
186
+ "prerequisites": "Conversations with agent involvement.",
187
+ "interpret": "Agents shows who is above or below the norm, Correlation shows what drives long chats, Recommendations suggests actions.",
188
+ "whyZero": "No handled conversations in range.",
189
+ "fix": "Widen the range or pick a team with traffic.",
190
+ "related": "Agent Performance, Chat Intelligence"
191
+ },
192
+ "intelligence": {
193
+ "name": "Intelligence Suite",
194
+ "answers": "AI analysis across agents, customers, knowledge gaps, volume forecasts, revenue impact, competitors and channels.",
195
+ "sources": "Conversation history, surveys, and connected commerce or CRM data where present.",
196
+ "prerequisites": "Feature enabled on the plan and a few weeks of data. Revenue Impact needs a commerce or CRM integration.",
197
+ "interpret": "Treat each tab as guidance. Predictive Volume is a forecast, not a guarantee. Revenue Impact is an estimate from connected data.",
198
+ "whyZero": "Not enough history, missing integration, or feature not enabled.",
199
+ "fix": "Connect the relevant integration and allow data to accumulate.",
200
+ "related": "Customer Health, Chat Intelligence"
201
+ },
202
+ "operations": {
203
+ "name": "Operations Center",
204
+ "answers": "Live view of queues, agents and conversations right now.",
205
+ "sources": "Real-time presence and queue state.",
206
+ "prerequisites": "Workforce management enabled.",
207
+ "interpret": "Use it for moment-to-moment staffing. A rising queue with idle agents suggests a routing problem.",
208
+ "whyZero": "No active conversations or agents online.",
209
+ "fix": "Confirm agents are online and routing is active.",
210
+ "related": "Agent Availability, Service Level"
211
+ },
212
+ "voice": {
213
+ "name": "Voice",
214
+ "answers": "Individual call history and voice usage and billing.",
215
+ "sources": "Call log and voice minute usage.",
216
+ "prerequisites": "Voice enabled with a phone number.",
217
+ "interpret": "Call Log for detail, Usage for minutes against your plan.",
218
+ "whyZero": "No voice line or no calls.",
219
+ "fix": "Set up a voice number.",
220
+ "related": "Calls, IVR"
221
+ },
222
+ "email-subscriptions": {
223
+ "name": "Email Reports",
224
+ "answers": "Scheduled report summaries emailed to you.",
225
+ "sources": "The same data as the reports, summarized on your schedule.",
226
+ "prerequisites": "Access to the underlying report; some are manager or admin only.",
227
+ "interpret": "Each email links back to the full report.",
228
+ "whyZero": "Subscription not saved, the report is not on your plan, or it had no data for the period.",
229
+ "fix": "Re-save the subscription and check spam filtering for noreply@velaro.com.",
230
+ "related": "All reports"
231
+ }
232
+ }
package/server.js CHANGED
@@ -40,7 +40,7 @@
40
40
  * VELARO_MCP_MODE=http PORT=3000 node server.js
41
41
  */
42
42
 
43
- import { buildRawLogQuery, describeRawMeta, fetchRetry429, resolveLogWindow } from './log-search.js';
43
+ import { fetchRetry429 } from './fetch-retry.js';
44
44
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
45
45
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
46
46
  import {
@@ -54,51 +54,12 @@ import { loadParity, runParity } from './parity/loader.js';
54
54
  import { resolveMessagingApi } from './messaging-base.js';
55
55
  import { buildResolvePath, buildTimelinePath, buildOverrideBody, buildValidateBody, buildBannerPath, buildOverrideListPath, tolerant, VALIDATE_PATH, OVERRIDE_PATH } from './workflow-schedule.js';
56
56
 
57
- // Default was 'https://help.velaro.com'. Renamed to the recognizable admin API hostname for
58
- // clarity during the 2026-09-10 Donaldson bot Calendly status investigation
59
- // (docs/qa/chatbot-hallucinated-booking-donaldson-bot.md) — but this is NOT a functional fix and
60
- // does NOT explain that incident: live-verified same day that 'https://help.velaro.com' and
61
- // 'https://api-admin-us-east.velaro.com' are the SAME deployment (identical /api/health build SHA,
62
- // and /Calendly/superadmin/status returns 401 — auth-gated, not 404 — on both hosts). Whatever
63
- // caused that investigation's status/reality contradiction, it was NOT this default resolving to a
64
- // broken or different host; that theory is disproven. Left as a naming clarity improvement only.
57
+ // Production admin API by default; set VELARO_ADMIN_API to target staging.
65
58
  const API_BASE = process.env.VELARO_ADMIN_API || 'https://api-admin-us-east.velaro.com';
66
59
  const MESSAGING_API = resolveMessagingApi(process.env.VELARO_MESSAGING_API, API_BASE);
67
60
  const MCP_KEY = process.env.VELARO_MCP_KEY;
68
61
  const JWT = process.env.VELARO_JWT;
69
62
 
70
- // mcp/staff-server.js defaults to staging instead -- the two servers' defaults intentionally
71
- // differ, but a zero-result lookup gives no hint which environment was actually searched
72
- // either way. See ENV_LABEL in staff-server.js for the incident this class of confusion caused
73
- // (a real production customer read as "doesn't exist" from a staging-scoped search).
74
- const ENV_LABEL = API_BASE.includes('staging') ? 'staging' : 'production';
75
-
76
- // Known admin API hosts, for tools that let a caller explicitly target one environment
77
- // regardless of what this server process's own API_BASE is configured to (see the `env` param
78
- // on the calendly_* superadmin tools below). Deliberately NOT used as a default anywhere —
79
- // defaulting env-selectable tools to one hardcoded environment while every other tool in this
80
- // file follows the single global API_BASE would recreate the exact bug being fixed here: an
81
- // operator configured for one environment would silently get a DIFFERENT environment's answer
82
- // from just these tools with no indication anything was different.
83
- const ADMIN_API_HOSTS = {
84
- staging: 'https://velaro-admin-staging.azurewebsites.net',
85
- production: 'https://api-admin-us-east.velaro.com',
86
- };
87
-
88
- /// Resolves an optional `env` tool argument to a target admin API base. Throws on an unrecognized
89
- /// value instead of silently falling through to API_BASE (the JS default-parameter behavior of
90
- /// `api(..., base)` when `base` is `undefined`) — a typo like `env: 'prod'` must not silently run
91
- /// a DESTRUCTIVE call (calendly_disconnect) against whatever this server defaults to while telling
92
- /// the caller it targeted a different environment. `hasOwnProperty` (not truthiness) also closes
93
- /// the prototype-chain edge case (`env: 'constructor'`, `env: 'toString'`, etc.).
94
- function resolveAdminBase(env) {
95
- if (env === undefined || env === null) return API_BASE;
96
- if (!Object.prototype.hasOwnProperty.call(ADMIN_API_HOSTS, env)) {
97
- throw new Error(`Unknown env "${env}" — use "staging" or "production" (or omit it to use this server's configured default).`);
98
- }
99
- return ADMIN_API_HOSTS[env];
100
- }
101
-
102
63
  if (!MCP_KEY && !JWT) {
103
64
  process.stderr.write('ERROR: Set VELARO_MCP_KEY=vel_live_... or VELARO_JWT=<token>\n');
104
65
  process.exit(1);
@@ -115,15 +76,6 @@ function formatHealthRow(item) {
115
76
  return ` ${(item.slug ?? '?').padEnd(14)} ${(item.status ?? '?').padEnd(14)} ${ms.padStart(7)} ${ts} ${item.detail ?? ''}`.trimEnd();
116
77
  }
117
78
 
118
- function parseDuration(s) {
119
- const m = /^(\d+)(m|h|d)$/i.exec(String(s ?? '2h').trim());
120
- if (!m) return 2 * 3600 * 1000;
121
- const n = parseInt(m[1], 10);
122
- if (m[2] === 'm') return n * 60 * 1000;
123
- if (m[2] === 'h') return n * 3600 * 1000;
124
- return n * 24 * 3600 * 1000;
125
- }
126
-
127
79
  async function messagingApi(path, opts = {}) { // opts.timeoutMs: log searches pass 40000 (server scan budget 12 s + 6 s AI search)
128
80
  if (!MESSAGING_API) throw new Error(
129
81
  'VELARO_MESSAGING_API is not configured. Add "VELARO_MESSAGING_API": "https://velaro-messaging-api-staging.azurewebsites.net" to your MCP env config.'
@@ -133,7 +85,6 @@ async function messagingApi(path, opts = {}) { // opts.timeoutMs: log searches
133
85
  method: opts.method ?? 'GET',
134
86
  headers: {
135
87
  Authorization: authHeader(),
136
- 'X-Internal-SiteId': '1032',
137
88
  ...(opts.body ? { 'Content-Type': 'application/json' } : {}),
138
89
  },
139
90
  body: opts.body ? JSON.stringify(opts.body) : undefined,
@@ -142,22 +93,11 @@ async function messagingApi(path, opts = {}) { // opts.timeoutMs: log searches
142
93
  const res = (opts.method ?? 'GET') === 'GET' ? await fetchRetry429(`${MESSAGING_API}${path}`, init) : await fetch(`${MESSAGING_API}${path}`, init());
143
94
  if (!res.ok) {
144
95
  const text = await res.text().catch(() => '');
145
- throw new Error(`Velaro Logger ${path} -> ${res.status}: ${text.slice(0, 300)}`);
96
+ throw new Error(`Messaging API ${path} -> ${res.status}: ${text.slice(0, 300)}`);
146
97
  }
147
98
  return res.json();
148
99
  }
149
100
 
150
- // FIXED 2026-09-24: velaro-messaging's /superadmin/logs/search used to return a bare JSON array
151
- // for some queries and {entries, nextCursor} for others, depending on which internal code path
152
- // served the request (see that repo's LogsController.cs ENVELOPE SHAPE comment). It now always
153
- // returns {entries, nextCursor} -- this helper also accepts a bare array so callers degrade
154
- // gracefully rather than throwing if ever pointed at an older deploy.
155
- function extractLogEntries(response) {
156
- if (Array.isArray(response)) return response;
157
- if (response && Array.isArray(response.entries)) return response.entries;
158
- return [];
159
- }
160
-
161
101
  /** Body for POST/PUT ApplePayPresetOrders: only passed fields; the server computes the total. */
162
102
  function applePayPresetBody(args) {
163
103
  const body = {};
@@ -334,51 +274,9 @@ async function api(method, path, body, base = API_BASE) {
334
274
  return text ? JSON.parse(text) : null;
335
275
  }
336
276
 
337
- // ── Livefluence migration helpers ─────────────────────────────────────────────
338
-
339
277
  import { fileURLToPath as _ftu } from 'node:url';
340
278
  import { join as _join, dirname as _dirname } from 'node:path';
341
- import { readFileSync as _readFileSync, existsSync as _existsSync } from 'node:fs';
342
-
343
- const _migrationsDir = _join(_dirname(_ftu(import.meta.url)), '..', 'cli', 'lib', 'migrations');
344
-
345
- function loadMigrationProfile(slug) {
346
- if (!slug) throw new Error('Provide customer slug (azenta, amscro, bluefire, donaldson) or a custom profile object.');
347
- const file = _join(_migrationsDir, `${slug}.json`);
348
- if (!_existsSync(file)) {
349
- throw new Error(`Migration profile "${slug}" not found. Available: azenta, amscro, bluefire, donaldson`);
350
- }
351
- return JSON.parse(_readFileSync(file, 'utf8'));
352
- }
353
-
354
- function mcpResolveTokens(tokenMap, items, idField, nameField) {
355
- const out = {};
356
- for (const [token, name] of Object.entries(tokenMap)) {
357
- const match = (items ?? []).find(i => i[nameField]?.toLowerCase() === name.toLowerCase());
358
- out[`@${token}`] = match ? String(match[idField]) : null;
359
- }
360
- return out;
361
- }
362
-
363
- function mcpResolveValue(value, aiMap, teamMap) {
364
- if (!value) return value;
365
- const out = {};
366
- for (const [k, v] of Object.entries(value)) {
367
- if (typeof v === 'string' && v.startsWith('@')) {
368
- out[k] = aiMap[v] ?? teamMap[v] ?? v;
369
- } else {
370
- out[k] = v;
371
- }
372
- }
373
- return out;
374
- }
375
-
376
- function mcpFindUnresolved(value) {
377
- if (!value) return [];
378
- return Object.entries(value)
379
- .filter(([, v]) => v === null || (typeof v === 'string' && v.startsWith('@')))
380
- .map(([k]) => k);
381
- }
279
+ import { readFileSync as _readFileSync } from 'node:fs';
382
280
 
383
281
  // The hosted gateway requires an initialized session (Mcp-Session-Id + MCP-Protocol-Version) on
384
282
  // every call after "initialize". Cache one per process and re-initialize when it expires (404).
@@ -1739,8 +1637,6 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
1739
1637
  },
1740
1638
  buttonLabel: { type: 'string', description: 'Optional action button label' },
1741
1639
  buttonUrl: { type: 'string', description: 'Optional action button URL (required if buttonLabel is set)' },
1742
- mentions: { type: 'array', description: 'People to @mention (real Teams notification). Velaro staff only. Uses the ops Teams sender instead of a webhook.', items: { type: 'object', properties: { user: { type: 'string', description: 'Entra object id or UPN, e.g. andrea@velaro.com' }, name: { type: 'string', description: 'Display name shown in the @mention' } }, required: ['user'] } },
1743
- destination: { type: 'string', description: 'Named channel in the DevOps team instead of General: prs, workflows, deploys, alerts, qa, general. Velaro staff only; uses the ops Teams sender instead of a webhook.' },
1744
1640
  },
1745
1641
  required: ['title'],
1746
1642
  },
@@ -2607,7 +2503,7 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
2607
2503
  },
2608
2504
  },
2609
2505
 
2610
- // ── Integration Diagnostics (Velaro staff) ────────────────────────────────
2506
+ // ── Integration Diagnostics ────────────────────────────────
2611
2507
  {
2612
2508
  name: 'integration_activity_list',
2613
2509
  description: 'List integration activity log entries (auth events, rate limits, config errors, AI skill calls). Fastest way to diagnose why a specific integration is failing — check this before App Insights.',
@@ -3277,7 +3173,7 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
3277
3173
  },
3278
3174
  {
3279
3175
  name: 'velaro_diagnostic_start',
3280
- description: 'START HERE for any "broken", "fix", "not working", "error", or "why is X failing" request. Returns the Velaro diagnostic runbook: which log tool to check first, query patterns for common symptoms (exceptions, AI skill errors, Hangfire failures, SignalR hub down, campaign issues), and the rule "check logs before reading code." Call this before touching any source file.',
3176
+ description: 'START HERE for any "broken", "not working", "error", or "why is X failing" request about your Velaro site. Returns the diagnostic runbook: which activity log, health and report tools to check first for integration, channel, bot and routing problems.',
3281
3177
  inputSchema: { type: 'object', properties: {}, required: [] },
3282
3178
  },
3283
3179
 
@@ -3797,7 +3693,7 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
3797
3693
  },
3798
3694
  {
3799
3695
  name: 'usage_alerts',
3800
- description: 'Plan-limit alerts for the authenticated site: every metered resource at or above its warning threshold (80%+), with used/limit, percent, level (warning | critical | limit) and whether overage is billed. Same rules as the usage-limit emails and the dashboard banner. Use to answer "are they near a limit?" or "why did they get a usage email?". Read-only. To raise a limit, a customer uses Billing > Contact Account Manager; staff use support_set_limit.',
3696
+ description: 'Plan-limit alerts for the authenticated site: every metered resource at or above its warning threshold (80%+), with used/limit, percent, level (warning | critical | limit) and whether overage is billed. Same rules as the usage-limit emails and the dashboard banner. Use to answer "are they near a limit?" or "why did they get a usage email?". Read-only. To raise a limit, use Billing > Contact Account Manager.',
3801
3697
  },
3802
3698
  {
3803
3699
  name: 'billing_contacts_list',
@@ -4167,6 +4063,77 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
4167
4063
  required: ['contactId'],
4168
4064
  },
4169
4065
  },
4066
+ // -- Current Sales (time-boxed bot override Q&A + per-customer coupons). Site comes from the credential, never an arg. --
4067
+ {
4068
+ name: 'site_sale_list',
4069
+ description: "List this site's Current Sales (temporary sales or discounts the bot answers about deterministically until they end).",
4070
+ inputSchema: { type: 'object', properties: {}, required: [] },
4071
+ },
4072
+ {
4073
+ name: 'site_sale_create',
4074
+ description: 'Create a Current Sale: while active, the bot answers sales, discount and coupon questions from this definition and can issue per-customer coupons (Magento sites mint a real cart price rule). It stops at the end time. Times are UTC; convert from Pacific.',
4075
+ inputSchema: {
4076
+ type: 'object',
4077
+ properties: {
4078
+ name: { type: 'string', description: 'Sale name shown to customers' },
4079
+ description: { type: 'string', description: 'Short description the bot can quote' },
4080
+ appliesTo: { type: 'string', enum: ['all','category','products'], description: 'Scope of the sale' },
4081
+ appliesToValue: { type: 'string', description: 'Category name or comma-separated product identifiers when appliesTo is category or products' },
4082
+ discountType: { type: 'string', enum: ['percentage','fixed_amount','free_shipping'], description: 'Kind of discount' },
4083
+ discountValue: { type: 'number', description: 'Percent or fixed amount (ignored for free_shipping)' },
4084
+ startsAtUtc: { type: 'string', description: 'ISO 8601 start time in UTC (convert from Pacific)' },
4085
+ endsAtUtc: { type: 'string', description: 'ISO 8601 end time in UTC; the sale stops answering at this time' },
4086
+ couponCode: { type: 'string', description: 'Optional shared coupon code' },
4087
+ autoGenerateCoupons: { type: 'boolean', description: 'Let the bot issue a unique coupon per customer' },
4088
+ maxCouponsPerCustomer: { type: 'number', description: 'Max coupons the bot issues to one customer' },
4089
+ couponUsageLimit: { type: 'number', description: 'Times each coupon can be used' },
4090
+ couponExpiresHours: { type: 'number', description: 'Hours before an issued coupon expires' },
4091
+ },
4092
+ required: ['name', 'discountType', 'startsAtUtc', 'endsAtUtc'],
4093
+ },
4094
+ },
4095
+ {
4096
+ name: 'site_sale_update',
4097
+ description: 'Update a Current Sale by id. Omit a field to leave it unchanged.',
4098
+ inputSchema: {
4099
+ type: 'object',
4100
+ properties: {
4101
+ id: { type: 'number', description: 'Sale ID from site_sale_list. Required.' },
4102
+ name: { type: 'string', description: 'Sale name shown to customers' },
4103
+ description: { type: 'string', description: 'Short description the bot can quote' },
4104
+ appliesTo: { type: 'string', enum: ['all','category','products'], description: 'Scope of the sale' },
4105
+ appliesToValue: { type: 'string', description: 'Category name or comma-separated product identifiers when appliesTo is category or products' },
4106
+ discountType: { type: 'string', enum: ['percentage','fixed_amount','free_shipping'], description: 'Kind of discount' },
4107
+ discountValue: { type: 'number', description: 'Percent or fixed amount (ignored for free_shipping)' },
4108
+ startsAtUtc: { type: 'string', description: 'ISO 8601 start time in UTC (convert from Pacific)' },
4109
+ endsAtUtc: { type: 'string', description: 'ISO 8601 end time in UTC; the sale stops answering at this time' },
4110
+ couponCode: { type: 'string', description: 'Optional shared coupon code' },
4111
+ autoGenerateCoupons: { type: 'boolean', description: 'Let the bot issue a unique coupon per customer' },
4112
+ maxCouponsPerCustomer: { type: 'number', description: 'Max coupons the bot issues to one customer' },
4113
+ couponUsageLimit: { type: 'number', description: 'Times each coupon can be used' },
4114
+ couponExpiresHours: { type: 'number', description: 'Hours before an issued coupon expires' },
4115
+ },
4116
+ required: ['id'],
4117
+ },
4118
+ },
4119
+ {
4120
+ name: 'site_sale_end',
4121
+ description: 'End a Current Sale now. The bot stops answering from it immediately.',
4122
+ inputSchema: {
4123
+ type: 'object',
4124
+ properties: { id: { type: 'number', description: 'Sale ID. Required.' } },
4125
+ required: ['id'],
4126
+ },
4127
+ },
4128
+ {
4129
+ name: 'site_sale_delete',
4130
+ description: 'Delete a Current Sale by id.',
4131
+ inputSchema: {
4132
+ type: 'object',
4133
+ properties: { id: { type: 'number', description: 'Sale ID. Required.' } },
4134
+ required: ['id'],
4135
+ },
4136
+ },
4170
4137
  {
4171
4138
  name: 'pickup_get_config',
4172
4139
  description: 'Get this site\'s Pickup & Reminder Texting configuration (default cadence, max reminders, message template, enabled state, quiet-hours override). Returns null/404 if no config has been created yet -- use pickup_update_config to create one.',
@@ -5432,12 +5399,12 @@ async function handleTool(name, args) {
5432
5399
  return await rpc('workflow_update_node', args);
5433
5400
 
5434
5401
  // ── Surveys ──────────────────────────────────────────────────────────
5435
- // Direct messagingGet/Post/Del calls (like notifications_get/ai_config_staff_get above) —
5402
+ // Direct messagingGet/Post/Del calls (like notifications_get above) —
5436
5403
  // site isolation is enforced server-side by SurveysController's [Authorize]-scoped SiteId
5437
5404
  // (ClientControllerBase), never passed by the client. Note: SurveysController's global
5438
5405
  // [Authorize] does not accept vel_live_* service keys (only a handful of controllers opt
5439
5406
  // into that manually) — these tools need VELARO_JWT, same requirement as the existing
5440
- // notifications_get/ai_config_staff_get tools they're modeled on.
5407
+ // notifications_get tool they're modeled on.
5441
5408
  case 'ai_followups_site_get': {
5442
5409
  const r = await messagingGet('/SiteSettings/AiFollowUps');
5443
5410
  return JSON.stringify({ enabled: r?.enabled ?? null }, null, 2);
@@ -5995,16 +5962,6 @@ reply: ${r?.reply}`;
5995
5962
  }
5996
5963
 
5997
5964
  case 'teams_notify_card': {
5998
- if ((args.mentions && args.mentions.length) || args.destination) {
5999
- const r = await api('POST', '/TeamsMessaging/Card', {
6000
- title: args.title,
6001
- facts: (args.fields || []).map(f => ({ label: f.label, value: f.value })),
6002
- actionUrl: args.buttonUrl, actionLabel: args.buttonLabel,
6003
- destination: args.destination,
6004
- mentions: (args.mentions || []).map(m => ({ user: m.user, name: m.name })),
6005
- });
6006
- return r?.sent ? `Card sent via ${r.path}${r.warnings?.length ? ' (' + r.warnings.join('; ') + ')' : ''}.` : `Send failed: ${r?.error || 'unknown error'}`;
6007
- }
6008
5965
  const webhooks = await api('GET', '/TeamsNotification');
6009
5966
  if (!Array.isArray(webhooks) || !webhooks.length)
6010
5967
  return 'No Teams webhooks configured. Go to Integrations → Microsoft Teams to add one, or call velaro teams create <name> <webhookUrl> via CLI.';
@@ -7389,7 +7346,9 @@ reply: ${r?.reply}`;
7389
7346
  }
7390
7347
 
7391
7348
  case 'report_explain': {
7392
- const all = JSON.parse(_readFileSync(_join(_dirname(_ftu(import.meta.url)), '..', 'cli', 'lib', 'report-explain.json'), 'utf8'));
7349
+ // Packaged copy (mcp/report-explain.json, kept identical to cli/lib/report-explain.json by
7350
+ // scripts/validate-cli-mcp-syntax.js). The old '../cli/lib' path does not exist in a package install.
7351
+ const all = JSON.parse(_readFileSync(_join(_dirname(_ftu(import.meta.url)), 'report-explain.json'), 'utf8'));
7393
7352
  const key = String(args.report ?? '').trim().toLowerCase();
7394
7353
  if (!key) return Object.entries(all).map(([k, r]) => `${k}: ${r.name}`).join('\n');
7395
7354
  const r = all[key];
@@ -8305,8 +8264,7 @@ ${JSON.stringify(result, null, 2)}`;
8305
8264
  lines.push('Recent payments:');
8306
8265
  s.recentSessions.forEach(p => lines.push(` ${(p.amountCents/100).toFixed(2)} ${p.status} ${p.createdAt?.slice(0,10)}`));
8307
8266
  }
8308
- // Note: QB sync, deferred revenue, and internal accounting details are omitted here.
8309
- // Use billing_plan_status_internal in staff-server.js for those.
8267
+ // QB sync, deferred revenue, and internal accounting details are intentionally omitted.
8310
8268
  return lines.join('\n');
8311
8269
  }
8312
8270
 
@@ -8454,6 +8412,66 @@ ${JSON.stringify(result, null, 2)}`;
8454
8412
  return JSON.stringify(history, null, 2);
8455
8413
  }
8456
8414
 
8415
+ case 'site_sale_list': {
8416
+ const result = await messagingGet('/SiteSales');
8417
+ return JSON.stringify(result, null, 2);
8418
+ }
8419
+
8420
+ case 'site_sale_create': {
8421
+ if (!args.name || !args.discountType || !args.startsAtUtc || !args.endsAtUtc) return 'name, discountType, startsAtUtc and endsAtUtc are required.';
8422
+ // Field names must match the SiteSales request on the messaging API exactly (undefined is dropped by JSON).
8423
+ const body = {
8424
+ name: args.name,
8425
+ description: args.description,
8426
+ appliesTo: args.appliesTo,
8427
+ appliesToValue: args.appliesToValue,
8428
+ discountType: args.discountType,
8429
+ discountValue: args.discountValue,
8430
+ startsAtUtc: args.startsAtUtc,
8431
+ endsAtUtc: args.endsAtUtc,
8432
+ couponCode: args.couponCode,
8433
+ autoGenerateCoupons: args.autoGenerateCoupons,
8434
+ maxCouponsPerCustomer: args.maxCouponsPerCustomer,
8435
+ couponUsageLimit: args.couponUsageLimit,
8436
+ couponExpiresHours: args.couponExpiresHours,
8437
+ };
8438
+ const result = await messagingPost('/SiteSales', body);
8439
+ return JSON.stringify(result, null, 2);
8440
+ }
8441
+
8442
+ case 'site_sale_update': {
8443
+ if (args.id == null) return 'id is required.';
8444
+ const body = {
8445
+ name: args.name,
8446
+ description: args.description,
8447
+ appliesTo: args.appliesTo,
8448
+ appliesToValue: args.appliesToValue,
8449
+ discountType: args.discountType,
8450
+ discountValue: args.discountValue,
8451
+ startsAtUtc: args.startsAtUtc,
8452
+ endsAtUtc: args.endsAtUtc,
8453
+ couponCode: args.couponCode,
8454
+ autoGenerateCoupons: args.autoGenerateCoupons,
8455
+ maxCouponsPerCustomer: args.maxCouponsPerCustomer,
8456
+ couponUsageLimit: args.couponUsageLimit,
8457
+ couponExpiresHours: args.couponExpiresHours,
8458
+ };
8459
+ const result = await messagingPut(`/SiteSales/${encodeURIComponent(args.id)}`, body);
8460
+ return JSON.stringify(result, null, 2);
8461
+ }
8462
+
8463
+ case 'site_sale_end': {
8464
+ if (args.id == null) return 'id is required.';
8465
+ const result = await messagingPost(`/SiteSales/${encodeURIComponent(args.id)}/end`, {});
8466
+ return JSON.stringify(result, null, 2);
8467
+ }
8468
+
8469
+ case 'site_sale_delete': {
8470
+ if (args.id == null) return 'id is required.';
8471
+ const result = await messagingDel(`/SiteSales/${encodeURIComponent(args.id)}`);
8472
+ return JSON.stringify(result, null, 2);
8473
+ }
8474
+
8457
8475
  case 'pickup_get_config': {
8458
8476
  const config = await messagingGet('/PickupNotifications/Config').catch(err => {
8459
8477
  if (String(err.message).includes(' 404')) return null;
@@ -9120,41 +9138,21 @@ ${JSON.stringify(result, null, 2)}`;
9120
9138
 
9121
9139
  const DIAGNOSTIC_RUNBOOK = `# Velaro Diagnostic Runbook
9122
9140
 
9123
- ## ALWAYS DO THIS FIRST before reading any code on a "broken" / "fix" / "not working" request
9124
-
9125
- ### Step 0 — Start here every time
9126
- 1. \`logs_appinsights_query\` env=staging → \`exceptions | where timestamp > ago(1h) | take 20\`
9127
- 2. \`logs_loggly_search\` → \`tag:velaro-messaging-Staging ERROR\` (last 1h)
9128
- 3. \`mcp__velaro__get_diagnostic\` or \`velaro ops\` for Hangfire queue / deploy version
9129
-
9130
- Reading code without checking logs first = debugging blind.
9131
-
9132
- ### Step 1 — Pick the right log source
9133
- | Symptom | Tool | Query pattern |
9134
- |---|---|---|
9135
- | Exceptions / 500s in admin | logs_appinsights_query env=staging | exceptions \| where timestamp > ago(1h) \| take 20 |
9136
- | Exceptions / 500s in messaging | logs_appinsights_query env=messaging-production | exceptions \| where timestamp > ago(1h) \| take 20 |
9137
- | AI skill errors | logs_loggly_search | tag:velaro-messaging-Staging SkillInvoked |
9138
- | Campaign / Hangfire failures | logs_loggly_search | tag:velaro-messaging-Staging Hangfire |
9139
- | IVR / SignalR hub down | signalr_diagnostics instance=both hub=MessengerHub |
9140
- | Prod exceptions | logs_appinsights_query env=production | exceptions \| take 20 |
9141
- | Secure forms errors | logs_appinsights_query env=secure-forms | exceptions \| take 20 |
9142
- | Scraper / automation errors | logs_appinsights_query env=automation-production | exceptions \| take 20 |
9143
-
9144
- ### Step 2 — SignalR alerts
9145
- Alerts: SignalR-CapSaturation-PAGE, SignalR-ServiceError-PAGE, SignalR-Quota95-PAGE are wired to velaro-ci-runners (Partner Success Core Benefits sub).
9146
- Use \`signalr_diagnostics hub=MessengerHub\` or \`hub=EngagementHub\` to check live connection counts.
9141
+ Check what Velaro already recorded before changing any configuration.
9147
9142
 
9148
- ### Step 3 — Integration issues
9149
- Use \`mcp__velaro__get_conversations\` or activity log endpoint \`/Integration/Logs/{slug}?severity=Error\` before digging into skill files.
9143
+ 1. Integration failures: \`integration_activity_list\` with severity=Error (filter by integration slug), then
9144
+ \`integration_skill_logs\` for AI skill calls that failed.
9145
+ 2. Channel or integration connectivity: \`integration_health\` (pass run=true with a slug to probe now).
9146
+ 3. Bot problems: \`bot_recent_errors\` and \`bot_skill_usage\`.
9147
+ 4. Volume or service-level changes: \`kpi_snapshot\` and \`service_level_report\`.
9148
+ 5. Routing questions: \`routing_explain_decision\` / \`routing_get_decision_log\`.
9150
9149
 
9151
- ### Step 4 — Only read code if logs are clean
9152
- If all log sources are quiet → then read source code to find the bug.`;
9150
+ If these show nothing unusual, contact Velaro support with the time window and conversation ids you checked.`;
9153
9151
 
9154
9152
  const PROMPTS = [
9155
9153
  {
9156
9154
  name: 'diagnostic_runbook',
9157
- description: 'Velaro diagnostic runbook — which log tool to use first, query patterns for common symptoms, SignalR alert reference. ALWAYS consult this before reading code on any "broken" or "fix" request.',
9155
+ description: 'Velaro diagnostic runbook: which activity log, health and report tools to check first when something on your site is not working.',
9158
9156
  arguments: [],
9159
9157
  },
9160
9158
  ];
@@ -16,59 +16,48 @@ const READ_TOKENS = new Set([
16
16
 
17
17
  // Names that contain a read-looking token but change state (checked before READ_TOKENS).
18
18
  const MUTATING = new Set([
19
- 'report_schedule_upsert', 'report_subscription_update', 'report_subscription_delete', 'report_export_start',
20
- 'report_backfill', 'report_data_export', 'executive_insights_subscribe', 'velaro_diagnostic_start',
21
- 'apple_test_control', 'kb_capture_screenshot', 'business_action_route', 'business_action_execute',
22
- 'netsuite_cti_generate_url', 'netsuite_m2m_init', 'netsuite_m2m_save', 'index_integrity_repair',
23
- 'klaviyo_migration_run', 'sitesync_blob_push', 'sitesync_blob_import', 'chat_intelligence_draft_article',
24
- 'teams_phone_set_config', 'webflow_set_config', 'notion_set_config', 'monday_set_field_mapping',
25
- 'teams_push_settings_update', 'teams_channel_config_update', 'routing_team_update_config',
26
- 'pickup_update_config', 'payment_recovery_update_config', 'recruiting_sync_jobs',
27
- 'ai_config_set_reply_format', 'ai_model_set_site_thinking', 'ai_model_set_site_pin',
28
- 'entitlement_reconcile', 'entitlement_sync_stripe', 'entitlement_backfill_subscriptions',
29
- 'calendly_configure', 'acuity_configure', 'ms_bookings_configure', 'magento_set_sidebar_fields',
19
+ 'report_schedule_upsert', 'report_subscription_update', 'report_subscription_delete',
20
+ 'report_export_start', 'report_backfill', 'report_data_export', 'executive_insights_subscribe',
21
+ 'velaro_diagnostic_start', 'business_action_route', 'business_action_execute', 'netsuite_cti_generate_url',
22
+ 'netsuite_m2m_init', 'netsuite_m2m_save', 'index_integrity_repair', 'klaviyo_migration_run',
23
+ 'chat_intelligence_draft_article', 'teams_phone_set_config', 'webflow_set_config', 'notion_set_config',
24
+ 'monday_set_field_mapping', 'teams_push_settings_update', 'teams_channel_config_update',
25
+ 'routing_team_update_config', 'pickup_update_config', 'payment_recovery_update_config',
26
+ 'recruiting_sync_jobs', 'ai_config_set_reply_format', 'magento_set_sidebar_fields',
30
27
  'zoom_phone_set_config',
28
+ 'site_sale_create', 'site_sale_update', 'site_sale_end',
31
29
  ]);
32
30
 
33
31
  // Names that look like writes but only read (checked before the write rules).
34
32
  const READ_ONLY_EXPLICIT = new Set([
35
- 'activity_logs', 'ai_field_extraction_fields', 'billing_subscription_invoice_send_log', 'billing_subscription_recognition_schedule',
36
- 'billing_subscription_payment_history', 'billing_subscription_invoice_history', 'billing_subscription_subscription_history',
37
- 'billing_subscription_upcoming', 'cobrowse_show', 'keyword_intelligence_top', 'keyword_intelligence_conversations',
38
- 'knowledge_advisor_advise', 'migrate_export', 'migrate_workflows_list', 'migrate_rules_list', 'quality_sentiment',
39
- 'quality_agent_performance', 'quality_low_scores', 'quality_calibration',
40
- 'appointments_find_open_slots', 'appointments_list', 'compliance_redaction_test', 'compliance_status',
41
- 'compliance_signatures', 'compliance_redaction_events', 'compliance_redaction_status',
42
- 'shopify_widget_activation_url', 'klaviyo_savings_calculator', 'routing_simulate_inactivity',
43
- 'outlook_calendar_get_available_range', 'ms_bookings_check_availability_range', 'copilot_studio_test',
44
- 'notion_test_token', 'teams_phone_test_config', 'webflow_test_config', 'magento_test_connection',
45
- 'outlook_calendar_test_connection', 'scraper_verify_token', 'bigcommerce_check_refundable',
46
- 'sitesync_blob_list', 'kb_advise_knowledge_strategy', 'index_move_status', 'index_mixed_content',
47
- 'index_search', 'netsuite_m2m_verify', 'bot_skill_usage', 'bot_full_diagnostic', 'bot_recent_errors',
48
- 'logs_loggly_search', 'logs_appinsights_query', 'velaro_logs_search', 'executive_brief', 'friction_radar',
49
- 'tour_insights', 'kpi_snapshot', 'site_info', 'case_board', 'case_list_types', 'ai_spend_report',
50
- 'agent_availability_report', 'missed_chat_diagnostics', 'sse_visitor_diagnostic', 'signalr_diagnostics',
51
- 'ivr_agent_availability', 'contactlist_get', 'ai_model_resolver_map', 'ai_model_effective_for_site',
52
- 'calendly_get_status', 'acuity_get_status', 'outlook_calendar_status', 'ms_bookings_status',
53
- 'service_level_report', 'call_activity_report', 'callrail_get_attributions',
54
- 'product_sync_status', 'report_export_status', 'report_data_export', 'coupon_grant_list', 'coupon_grant_get',
55
- 'automation_draft_list', 'focus_get_draft_pregeneration_report', 'teams_push_settings_get',
56
- 'call_center_team_breakdown', 'call_center_channel_breakdown', 'outbound_activity_for_contact',
57
- 'integration_skill_logs', 'support_integration_logs', 'site_integration_logs', 'magento_activity_logs',
58
- 'support_site_deployments', 'support_schedule_adherence', 'support_site_agents', 'support_site_activity',
59
- 'chat_intelligence_themes', 'chat_intelligence_recommendations', 'index_move_preview', 'index_split_preview',
60
- 'canned_learning_suggestions', 'canned_learning_teams', 'canned_learning_edit_summary',
61
- 'chat_topics_group_suggestions', 'chat_topics_suggestions', 'enterprise_reports', 'image_analysis_limits',
62
- 'avatar_session_readiness', 'recruiting_ats_failures', 'jobboard_application_history',
63
- 'recruiting_download_resume', 'routing_inactivity_transfers', 'entitlement_enterprise_reports_package',
64
- 'compliance_attestation', 'livefluence_conversion_advisor', 'copilot_studio_ask',
65
- 'skill_routing_simulate', 'squarespace_test_connection', 'woocommerce_logs', 'site_builder_connect_info',
33
+ 'activity_logs', 'ai_field_extraction_fields', 'keyword_intelligence_top',
34
+ 'keyword_intelligence_conversations', 'knowledge_advisor_advise', 'quality_sentiment',
35
+ 'quality_agent_performance', 'quality_low_scores', 'quality_calibration', 'appointments_find_open_slots',
36
+ 'appointments_list', 'compliance_redaction_test', 'compliance_status', 'compliance_signatures',
37
+ 'compliance_redaction_events', 'compliance_redaction_status', 'shopify_widget_activation_url',
38
+ 'klaviyo_savings_calculator', 'routing_simulate_inactivity', 'outlook_calendar_get_available_range',
39
+ 'ms_bookings_check_availability_range', 'copilot_studio_test', 'notion_test_token',
40
+ 'teams_phone_test_config', 'webflow_test_config', 'magento_test_connection', 'scraper_verify_token',
41
+ 'kb_advise_knowledge_strategy', 'index_move_status', 'index_mixed_content', 'index_search',
42
+ 'netsuite_m2m_verify', 'bot_skill_usage', 'bot_recent_errors', 'executive_brief', 'friction_radar',
43
+ 'kpi_snapshot', 'site_info', 'case_board', 'case_list_types', 'ivr_agent_availability', 'contactlist_get',
44
+ 'service_level_report', 'call_activity_report', 'callrail_get_attributions', 'product_sync_status',
45
+ 'report_export_status', 'report_data_export', 'automation_draft_list',
46
+ 'focus_get_draft_pregeneration_report', 'teams_push_settings_get', 'call_center_team_breakdown',
47
+ 'call_center_channel_breakdown', 'outbound_activity_for_contact', 'integration_skill_logs',
48
+ 'magento_activity_logs', 'chat_intelligence_themes', 'chat_intelligence_recommendations',
49
+ 'index_move_preview', 'index_split_preview', 'canned_learning_suggestions', 'canned_learning_teams',
50
+ 'canned_learning_edit_summary', 'chat_topics_group_suggestions', 'chat_topics_suggestions',
51
+ 'enterprise_reports', 'recruiting_ats_failures', 'jobboard_application_history',
52
+ 'recruiting_download_resume', 'routing_inactivity_transfers', 'compliance_attestation',
53
+ 'copilot_studio_ask', 'skill_routing_simulate', 'squarespace_test_connection', 'woocommerce_logs',
54
+ 'site_builder_connect_info',
66
55
  ]);
67
56
 
68
57
  // Write tools that are additive even though their name carries a destructive-looking token.
69
58
  const NON_DESTRUCTIVE_EXPLICIT = new Set([
70
- 'safety_settings_test', 'safety_need_help', 'canned_learning_record_edit', 'hangfire_retry_job',
71
- 'netsuite_suiteapp_download', 'chat_topics_reclassify',
59
+ 'safety_settings_test', 'safety_need_help', 'canned_learning_record_edit', 'netsuite_suiteapp_download',
60
+ 'chat_topics_reclassify',
72
61
  ]);
73
62
 
74
63
  const DESTRUCTIVE_TOKENS = new Set([
@@ -93,14 +82,12 @@ const NOT_IDEMPOTENT_TOKENS = new Set([
93
82
  ]);
94
83
 
95
84
  // Tools that reach a system outside the caller's Velaro account (third-party APIs, email/SMS/chat delivery, CRMs).
96
- const OPEN_WORLD = new RegExp('^(' + [
97
- 'calendly', 'acuity', 'outlook', 'ms_bookings', 'bigcommerce', 'shopify', 'magento', 'sap', 'netsuite',
98
- 'monday', 'notion', 'teams', 'slack', 'webflow', 'klaviyo', 'callrail', 'copilot_studio', 'scraper',
99
- 'site_scraper', 'apple', 'mobile_ota', 'code_push', 'jobboard', 'recruiting', 'payment_recovery',
100
- 'pickup_notify', 'email_queue_replay', 'email_inbox', 'entitlement_sync_stripe', 'logs_loggly',
101
- 'logs_appinsights', 'livefluence', 'telephony', 'kb_capture_screenshot', 'business_action',
102
- 'sitesync', 'email_delivery', 'campaign_sender_auth', 'ivr_update_line',
103
- ].join('|') + ')');
85
+ const OPEN_WORLD_PREFIXES = [
86
+ 'outlook', 'ms_bookings', 'shopify', 'magento', 'netsuite', 'monday', 'notion', 'teams', 'slack',
87
+ 'webflow', 'klaviyo', 'callrail', 'copilot_studio', 'scraper', 'apple', 'jobboard', 'recruiting',
88
+ 'payment_recovery', 'pickup_notify', 'telephony', 'business_action', 'campaign_sender_auth',
89
+ 'ivr_update_line',
90
+ ];
104
91
 
105
92
  const ACRONYMS = {
106
93
  kb: 'KB', ai: 'AI', ivr: 'IVR', sap: 'SAP', crm: 'CRM', sse: 'SSE', api: 'API', cti: 'CTI', m2m: 'M2M',
@@ -117,29 +104,45 @@ export function titleFor(name) {
117
104
  .join(' ');
118
105
  }
119
106
 
120
- export function annotationsFor(name) {
121
- const t = tokens(name);
122
- const has = (set) => t.some((x) => set.has(x));
123
- const readOnly = READ_ONLY_EXPLICIT.has(name)
124
- || (!MUTATING.has(name) && has(READ_TOKENS) && !has(DESTRUCTIVE_TOKENS) && !has(ADDITIVE_TOKENS));
125
- const openWorldHint = OPEN_WORLD.test(name);
126
- if (readOnly) {
127
- return { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint };
107
+ // Builds the rule set. The public package uses the base rules only; mcp/staff-tool-annotations.js (never
108
+ // published) adds the staff tools' entries, so no staff tool name ships in this file (guard:
109
+ // scripts/validate-mcp-no-staff-tools.js).
110
+ export function makeAnnotator(extra = {}) {
111
+ const mutating = new Set([...MUTATING, ...(extra.mutating ?? [])]);
112
+ const readOnlyExplicit = new Set([...READ_ONLY_EXPLICIT, ...(extra.readOnly ?? [])]);
113
+ const nonDestructiveExplicit = new Set([...NON_DESTRUCTIVE_EXPLICIT, ...(extra.nonDestructive ?? [])]);
114
+ const openWorld = new RegExp('^(' + [...OPEN_WORLD_PREFIXES, ...(extra.openWorldPrefixes ?? [])].join('|') + ')');
115
+
116
+ function annotationsFor(name) {
117
+ const t = tokens(name);
118
+ const has = (set) => t.some((x) => set.has(x));
119
+ const readOnly = readOnlyExplicit.has(name)
120
+ || (!mutating.has(name) && has(READ_TOKENS) && !has(DESTRUCTIVE_TOKENS) && !has(ADDITIVE_TOKENS));
121
+ const openWorldHint = openWorld.test(name);
122
+ if (readOnly) {
123
+ return { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint };
124
+ }
125
+ const additive = nonDestructiveExplicit.has(name) || (has(ADDITIVE_TOKENS) && !has(DESTRUCTIVE_TOKENS));
126
+ return {
127
+ readOnlyHint: false,
128
+ destructiveHint: !additive,
129
+ idempotentHint: !has(NOT_IDEMPOTENT_TOKENS),
130
+ openWorldHint,
131
+ };
128
132
  }
129
- const additive = NON_DESTRUCTIVE_EXPLICIT.has(name) || (has(ADDITIVE_TOKENS) && !has(DESTRUCTIVE_TOKENS));
130
- return {
131
- readOnlyHint: false,
132
- destructiveHint: !additive,
133
- idempotentHint: !has(NOT_IDEMPOTENT_TOKENS),
134
- openWorldHint,
135
- };
136
- }
137
133
 
138
- // Returns the tool with `title` and `annotations` filled in; a hand-written value on the tool wins.
139
- export function withAnnotations(tool) {
140
- return {
141
- ...tool,
142
- title: tool.title ?? titleFor(tool.name),
143
- annotations: { ...annotationsFor(tool.name), ...(tool.annotations ?? {}) },
144
- };
134
+ // Returns the tool with `title` and `annotations` filled in; a hand-written value on the tool wins.
135
+ function withAnnotations(tool) {
136
+ return {
137
+ ...tool,
138
+ title: tool.title ?? titleFor(tool.name),
139
+ annotations: { ...annotationsFor(tool.name), ...(tool.annotations ?? {}) },
140
+ };
141
+ }
142
+
143
+ return { annotationsFor, withAnnotations };
145
144
  }
145
+
146
+ const BASE = makeAnnotator();
147
+ export const annotationsFor = BASE.annotationsFor;
148
+ export const withAnnotations = BASE.withAnnotations;
package/log-search.js DELETED
@@ -1,252 +0,0 @@
1
- // Complete Velaro Logger reads (2026-10-14).
2
- //
3
- // WHY: velaro-messaging's /superadmin/logs/search and /internal/logs/search silently under-reported text queries.
4
- // Proven live that day: q=[HttpFallback] over 3.9 h returned 0-2 rows while 136 existed, and q=AsrsNegotiateFailed
5
- // (no level) returned 0 while 4 existed. The AI-search path only saw the newest 1,000 candidates and its answer was
6
- // final; the exact path only saw the newest 5,000 rows of the window before filtering. Neither said so.
7
- //
8
- // TWO MODES, picked automatically from the first response:
9
- // * CURSOR mode (server with velaro-messaging PR #2510, response carries `truncated`/`scannedRows`): the server scans the
10
- // window newest-first applying level/site AND text (as an escaped regex, which also forces the exact path) while it
11
- // scans, reports `truncated`, and returns `nextCursor` whenever more rows may exist. We follow the cursor until it is
12
- // null, so a 2 h window is a handful of requests instead of hundreds.
13
- // * BISECT mode (older server): never send q=; push level/site/etc server side; includeTotal=true reports
14
- // totalCountCapped for a slice; a capped slice is bisected until none is capped; text is filtered client side.
15
- // Either way the result says COMPLETE or INCOMPLETE, so a 0 is never ambiguous. Hard caps: maxRequests and maxSeconds.
16
- //
17
- // KEEP IN SYNC: this file is duplicated byte for byte in mcp/log-search.js (the npm packages ship separately);
18
- // cli/lib/log-search.test.js fails if the two copies drift.
19
-
20
- const MIN_SLICE_MS = 2000;
21
-
22
- export const escapeRegex = (s) => String(s).replace(/[\\^$.*+?()[\]{}|]/g, '\\$&');
23
-
24
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
25
- const tsOf = (e) => { const t = Date.parse(e.timestamp ?? e.LogTimestamp ?? ''); return Number.isNaN(t) ? 0 : t; };
26
-
27
- /**
28
- * fetch that retries ONLY 429 (honouring Retry-After, seconds, capped at 30 s) for up to `retries` attempts and returns the final Response.
29
- * `init` may be a function so each attempt gets a fresh AbortSignal.
30
- */
31
- export async function fetchRetry429(url, init = {}, { retries = 3, fetchImpl = fetch, sleepImpl = sleep } = {}) {
32
- for (let attempt = 0; ; attempt++) {
33
- const res = await fetchImpl(url, typeof init === 'function' ? init() : init);
34
- if (res.status === 429 && attempt < retries) {
35
- // Number(null) is 0, which would turn a 429 without Retry-After into an immediate retry: a missing header means the 5 s default.
36
- const raw = res.headers?.get?.('retry-after');
37
- const ra = raw == null || raw === '' ? NaN : Number(raw);
38
- await sleepImpl(Math.min(Number.isFinite(ra) && ra >= 0 ? ra : 5, 30) * 1000);
39
- continue;
40
- }
41
- return res;
42
- }
43
- }
44
-
45
- /** fetchRetry429 + JSON. Any non-2xx throws an Error carrying `.status` and the response text. */
46
- export async function fetchJsonRetry(url, init = {}, opts = {}) {
47
- const res = await fetchRetry429(url, init, opts);
48
- if (!res.ok) {
49
- const err = new Error(`HTTP ${res.status}: ${(await res.text().catch(() => '')).slice(0, 400)}`);
50
- err.status = res.status;
51
- throw err;
52
- }
53
- return res.json();
54
- }
55
-
56
- /**
57
- * @param {object} o
58
- * @param {(query: string) => Promise<any>} o.fetchJson GET `...search?<query>` and return parsed JSON (caller adds auth/timeout)
59
- * @param {Date} o.from
60
- * @param {Date} o.to
61
- * @param {object} [o.params] server-side filters: level, siteId, source, integration, platform, environment, correlationId
62
- * @param {string} [o.contains] case-insensitive substring of the message
63
- * @param {string} [o.regex] regex on the message
64
- * @param {string} [o.logger] substring of the logger name
65
- * @param {number} [o.maxRequests=200]
66
- * @param {number} [o.maxSeconds=120] wall-clock budget for the whole read
67
- * @param {number} [o.wanted=Infinity] CURSOR mode: stop once this many matches were collected (newest first); the result is then `satisfied`
68
- */
69
- export async function completeLogSearch({ fetchJson, from, to, params = {}, contains, regex, logger, maxRequests = 200, maxSeconds = 120, wanted = Infinity }) {
70
- const server = {};
71
- for (const k of ['level', 'siteId', 'source', 'integration', 'platform', 'environment', 'correlationId']) {
72
- if (params[k] !== undefined && params[k] !== null && params[k] !== '') server[k] = String(k === 'level' ? String(params[k]).toUpperCase() : params[k]);
73
- }
74
- const byId = new Map();
75
- const unresolved = [];
76
- const started = Date.now();
77
- let requests = 0;
78
- let slices = 0;
79
- let scanned = 0;
80
- let budgetExhausted = false;
81
- let timeExhausted = false;
82
-
83
- const canRequest = () => {
84
- if (requests >= maxRequests) { budgetExhausted = true; return false; }
85
- if ((Date.now() - started) / 1000 >= maxSeconds && requests > 0) { timeExhausted = true; return false; }
86
- return true;
87
- };
88
- const norm = (r) => (Array.isArray(r) ? { entries: r } : (r || {}));
89
- const add = (e) => byId.set(e.id ?? `${e.timestamp}|${e.message}`, e);
90
-
91
- // ---- text handling shared by both modes ----
92
- const textQ = regex ?? (contains ? escapeRegex(contains) : undefined);
93
- const textLogger = logger ? escapeRegex(logger) : undefined;
94
-
95
- // ---- probe: first CURSOR-mode page, doubles as the new-server detector ----
96
- const cursorQuery = (cursor) => {
97
- const q = new URLSearchParams({ from: from.toISOString(), to: to.toISOString(), take: '500', ...server });
98
- if (textQ) q.set('q', textQ);
99
- if (textLogger) q.set('logger', textLogger);
100
- if (textQ || textLogger) q.set('regex', 'true'); // regex also forces the exact path (the AI-search path is never consulted)
101
- q.set('fallthrough', 'false');
102
- if (cursor) q.set('cursor', cursor);
103
- return q.toString();
104
- };
105
-
106
- let mode = 'bisect';
107
- let satisfied = false;
108
- let cursorDone = false;
109
-
110
- if (canRequest()) {
111
- requests++;
112
- const probe = norm(await fetchJson(cursorQuery(null)));
113
- if (typeof probe.truncated === 'boolean') {
114
- mode = 'cursor';
115
- scanned += Number(probe.scannedRows ?? 0);
116
- for (const e of probe.entries ?? []) add(e);
117
- let page = probe;
118
- let cursor = probe.nextCursor || null;
119
- for (;;) {
120
- // The ONLY way a cursor read is COMPLETE: the last page said truncated === false and carried no cursor. `truncated: true` with no
121
- // cursor means the server stopped scanning (deadline/cap) and could not say where to continue: INCOMPLETE, never COMPLETE.
122
- if (!cursor) {
123
- if (page.truncated === false) cursorDone = true;
124
- else unresolved.push('the server stopped scanning (truncated) without a cursor to continue from');
125
- break;
126
- }
127
- if (byId.size >= wanted) { satisfied = true; break; }
128
- if (!canRequest()) { unresolved.push(`cursor ${cursor} (${timeExhausted ? 'time budget' : 'request budget'})`); break; }
129
- requests++;
130
- const next = norm(await fetchJson(cursorQuery(cursor)));
131
- scanned += Number(next.scannedRows ?? 0);
132
- for (const e of next.entries ?? []) add(e);
133
- if (next.nextCursor && next.nextCursor === cursor) {
134
- unresolved.push('no progress: the server returned the cursor it was given (its scan budget ended before reaching any row)');
135
- break;
136
- }
137
- page = next;
138
- cursor = next.nextCursor || null;
139
- }
140
- slices = 1;
141
- }
142
- }
143
-
144
- // ---- BISECT mode (older server) ----
145
- if (mode === 'bisect' && !budgetExhausted) {
146
- const call = async (a, b, cursor, total) => {
147
- if (!canRequest()) return null;
148
- requests++;
149
- const q = new URLSearchParams({ from: a.toISOString(), to: b.toISOString(), take: '500', ...server });
150
- if (total) q.set('includeTotal', 'true');
151
- if (cursor) q.set('cursor', cursor);
152
- return norm(await fetchJson(q.toString()));
153
- };
154
- const walk = async (a, b) => {
155
- const r = await call(a, b, null, true);
156
- if (!r) { unresolved.push(`${a.toISOString()}..${b.toISOString()} (${timeExhausted ? 'time' : 'request'} budget)`); return; }
157
- if (r.totalCountCapped === true && (b - a) > MIN_SLICE_MS) {
158
- const mid = new Date(a.getTime() + Math.floor((b - a) / 2));
159
- await walk(a, mid);
160
- await walk(mid, b);
161
- return;
162
- }
163
- if (r.totalCountCapped === true) unresolved.push(`${a.toISOString()}..${b.toISOString()} (still capped at <=2s: a log storm; add level/site)`);
164
- slices++;
165
- scanned += Number(r.totalCount ?? (r.entries?.length ?? 0));
166
- for (const e of r.entries ?? []) add(e);
167
- let cursor = r.nextCursor;
168
- while (cursor) {
169
- const r2 = await call(a, b, cursor, false);
170
- if (!r2) { unresolved.push(`${a.toISOString()}..${b.toISOString()} (budget while paging)`); break; }
171
- for (const e of r2.entries ?? []) add(e);
172
- cursor = r2.nextCursor;
173
- }
174
- };
175
- await walk(from, to);
176
- }
177
-
178
- let rows = [...byId.values()];
179
- const msg = (e) => String(e.message ?? e.Message ?? '');
180
- if (mode === 'bisect') {
181
- if (contains) { const c = contains.toLowerCase(); rows = rows.filter((e) => msg(e).toLowerCase().includes(c)); }
182
- if (regex) { const re = new RegExp(regex, 'i'); rows = rows.filter((e) => re.test(msg(e))); }
183
- if (logger) { const l = logger.toLowerCase(); rows = rows.filter((e) => String(e.logger ?? e.Logger ?? '').toLowerCase().includes(l)); }
184
- }
185
- rows.sort((x, y) => tsOf(y) - tsOf(x)); // compare as instants: fractional-second digit counts differ between rows
186
-
187
- const complete = mode === 'cursor' ? (cursorDone && unresolved.length === 0) : (unresolved.length === 0 && !budgetExhausted && !timeExhausted);
188
- return { entries: rows, fetched: byId.size, scanned, requests, slices, mode, complete, satisfied, unresolved, budgetExhausted, timeExhausted };
189
- }
190
-
191
- /** One line a human or an LLM can trust: says COMPLETE or INCOMPLETE and how much the SERVER actually scanned. */
192
- export function describeCompleteness(r) {
193
- const base = `${r.entries.length} matched (${r.fetched} fetched; server scanned ${r.scanned} rows) in ${r.requests} request(s), ${r.mode} mode`;
194
- if (r.satisfied) return `${base}; showing the newest matches, more may exist beyond what was asked for (stopped early on purpose).`;
195
- return r.complete
196
- ? `${base}; COMPLETE (the whole window was covered), so 0 matches means nothing was logged.`
197
- : `${base}; INCOMPLETE (${r.unresolved.length} part(s) not covered${r.budgetExhausted ? ', request budget exhausted' : ''}${r.timeExhausted ? ', time budget exhausted' : ''}): narrow the window or add level/site; a short or empty result is NOT proof of absence.`;
198
- }
199
-
200
- /** For raw (server-side q=) responses: surfaces searchPath/scannedRows/truncated so a 0 is never ambiguous. */
201
- export function describeRawMeta(resp) {
202
- if (!resp || Array.isArray(resp) || resp.scannedRows === undefined) {
203
- return 'server did not report scan metadata (older deploy): treat an empty or short result as unverified, use the complete mode.';
204
- }
205
- const t = resp.truncated === true;
206
- // exact=false: the answer involves the AI-search index, which is best-effort (written behind, can lag, misses text that starts mid-word).
207
- const bestEffort = resp.exact === false ? ' exact=false (answered from the best-effort search index: it can lag and misses text that starts mid-word, so treat counts as a floor, not exact)' : '';
208
- return `searchPath=${resp.searchPath} scannedRows=${resp.scannedRows} truncated=${t}${t ? ' (NOT proof of absence; continue with nextCursor or narrow the window)' : ''}${bestEffort}${resp.nextCursor ? ' nextCursor=present' : ''}`;
209
- }
210
-
211
- // ── Time window + raw query building (shared by server.js and staff-server.js velaro_logs_search) ──
212
- // Bug fixed 2026-10-21: the tool only honored `last` and silently ignored from/to, so an explicit
213
- // historical range returned the newest entries instead. from/to accept an ISO timestamp or a
214
- // relative duration (30m, 2h, 1d = that long ago). `to` defaults to now, `from` to to - last (2h).
215
- const DUR_RE = /^(\d+)(m|h|d)$/i;
216
- const durMs = (s) => {
217
- const m = DUR_RE.exec(String(s).trim());
218
- const n = parseInt(m[1], 10);
219
- const u = m[2].toLowerCase();
220
- return u === 'm' ? n * 60000 : u === 'h' ? n * 3600000 : n * 86400000;
221
- };
222
- function parseWhen(label, v, nowMs) {
223
- const s = String(v).trim();
224
- if (DUR_RE.test(s)) return new Date(nowMs - durMs(s));
225
- const d = new Date(s);
226
- if (Number.isNaN(d.getTime())) throw new Error(`Invalid ${label} "${v}": use an ISO timestamp (2026-10-20T00:00:00Z) or a relative duration like 2h.`);
227
- return d;
228
- }
229
- export function resolveLogWindow(args = {}, nowMs = Date.now()) {
230
- const given = (v) => v !== undefined && v !== null && String(v).trim() !== '';
231
- const to = given(args.to) ? parseWhen('to', args.to, nowMs) : new Date(nowMs);
232
- const lastMs = DUR_RE.test(String(args.last ?? '').trim()) ? durMs(args.last) : 2 * 3600000;
233
- const from = given(args.from) ? parseWhen('from', args.from, nowMs) : new Date(to.getTime() - lastMs);
234
- if (from.getTime() >= to.getTime()) throw new Error(`Invalid window: from (${from.toISOString()}) must be before to (${to.toISOString()}).`);
235
- return { from, to };
236
- }
237
-
238
- // Query string for GET /superadmin/logs/search (same params as /internal/logs/search).
239
- export function buildRawLogQuery(args, from, to) {
240
- const params = new URLSearchParams({ from: from.toISOString(), to: to.toISOString(), take: String(args.take ?? 50) });
241
- if (args.q) params.set('q', args.q);
242
- if (args.level) params.set('level', String(args.level).toUpperCase());
243
- if (args.logger) params.set('logger', args.logger);
244
- if (args.integration) params.set('integration', args.integration);
245
- if (args.siteId) params.set('siteId', String(args.siteId));
246
- if (args.source) params.set('source', args.source);
247
- if (args.platform) params.set('platform', args.platform);
248
- if (args.environment) params.set('environment', args.environment);
249
- if (args.correlationId) params.set('correlationId', args.correlationId);
250
- if (args.regex) params.set('regex', 'true');
251
- return params.toString();
252
- }