@velaro/mcp-server 2.0.0 → 2.0.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.
@@ -16,59 +16,47 @@ 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',
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',
31
28
  ]);
32
29
 
33
30
  // Names that look like writes but only read (checked before the write rules).
34
31
  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',
32
+ 'activity_logs', 'ai_field_extraction_fields', 'keyword_intelligence_top',
33
+ 'keyword_intelligence_conversations', 'knowledge_advisor_advise', 'quality_sentiment',
34
+ 'quality_agent_performance', 'quality_low_scores', 'quality_calibration', 'appointments_find_open_slots',
35
+ 'appointments_list', 'compliance_redaction_test', 'compliance_status', 'compliance_signatures',
36
+ 'compliance_redaction_events', 'compliance_redaction_status', 'shopify_widget_activation_url',
37
+ 'klaviyo_savings_calculator', 'routing_simulate_inactivity', 'outlook_calendar_get_available_range',
38
+ 'ms_bookings_check_availability_range', 'copilot_studio_test', 'notion_test_token',
39
+ 'teams_phone_test_config', 'webflow_test_config', 'magento_test_connection', 'scraper_verify_token',
40
+ 'kb_advise_knowledge_strategy', 'index_move_status', 'index_mixed_content', 'index_search',
41
+ 'netsuite_m2m_verify', 'bot_skill_usage', 'bot_recent_errors', 'executive_brief', 'friction_radar',
42
+ 'kpi_snapshot', 'site_info', 'case_board', 'case_list_types', 'ivr_agent_availability', 'contactlist_get',
43
+ 'service_level_report', 'call_activity_report', 'callrail_get_attributions', 'product_sync_status',
44
+ 'report_export_status', 'report_data_export', 'automation_draft_list',
45
+ 'focus_get_draft_pregeneration_report', 'teams_push_settings_get', 'call_center_team_breakdown',
46
+ 'call_center_channel_breakdown', 'outbound_activity_for_contact', 'integration_skill_logs',
47
+ 'magento_activity_logs', 'chat_intelligence_themes', 'chat_intelligence_recommendations',
48
+ 'index_move_preview', 'index_split_preview', 'canned_learning_suggestions', 'canned_learning_teams',
49
+ 'canned_learning_edit_summary', 'chat_topics_group_suggestions', 'chat_topics_suggestions',
50
+ 'enterprise_reports', 'recruiting_ats_failures', 'jobboard_application_history',
51
+ 'recruiting_download_resume', 'routing_inactivity_transfers', 'compliance_attestation',
52
+ 'copilot_studio_ask', 'skill_routing_simulate', 'squarespace_test_connection', 'woocommerce_logs',
53
+ 'site_builder_connect_info',
66
54
  ]);
67
55
 
68
56
  // Write tools that are additive even though their name carries a destructive-looking token.
69
57
  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',
58
+ 'safety_settings_test', 'safety_need_help', 'canned_learning_record_edit', 'netsuite_suiteapp_download',
59
+ 'chat_topics_reclassify',
72
60
  ]);
73
61
 
74
62
  const DESTRUCTIVE_TOKENS = new Set([
@@ -93,14 +81,12 @@ const NOT_IDEMPOTENT_TOKENS = new Set([
93
81
  ]);
94
82
 
95
83
  // 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('|') + ')');
84
+ const OPEN_WORLD_PREFIXES = [
85
+ 'outlook', 'ms_bookings', 'shopify', 'magento', 'netsuite', 'monday', 'notion', 'teams', 'slack',
86
+ 'webflow', 'klaviyo', 'callrail', 'copilot_studio', 'scraper', 'apple', 'jobboard', 'recruiting',
87
+ 'payment_recovery', 'pickup_notify', 'telephony', 'business_action', 'campaign_sender_auth',
88
+ 'ivr_update_line',
89
+ ];
104
90
 
105
91
  const ACRONYMS = {
106
92
  kb: 'KB', ai: 'AI', ivr: 'IVR', sap: 'SAP', crm: 'CRM', sse: 'SSE', api: 'API', cti: 'CTI', m2m: 'M2M',
@@ -117,29 +103,45 @@ export function titleFor(name) {
117
103
  .join(' ');
118
104
  }
119
105
 
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 };
106
+ // Builds the rule set. The public package uses the base rules only; mcp/staff-tool-annotations.js (never
107
+ // published) adds the staff tools' entries, so no staff tool name ships in this file (guard:
108
+ // scripts/validate-mcp-no-staff-tools.js).
109
+ export function makeAnnotator(extra = {}) {
110
+ const mutating = new Set([...MUTATING, ...(extra.mutating ?? [])]);
111
+ const readOnlyExplicit = new Set([...READ_ONLY_EXPLICIT, ...(extra.readOnly ?? [])]);
112
+ const nonDestructiveExplicit = new Set([...NON_DESTRUCTIVE_EXPLICIT, ...(extra.nonDestructive ?? [])]);
113
+ const openWorld = new RegExp('^(' + [...OPEN_WORLD_PREFIXES, ...(extra.openWorldPrefixes ?? [])].join('|') + ')');
114
+
115
+ function annotationsFor(name) {
116
+ const t = tokens(name);
117
+ const has = (set) => t.some((x) => set.has(x));
118
+ const readOnly = readOnlyExplicit.has(name)
119
+ || (!mutating.has(name) && has(READ_TOKENS) && !has(DESTRUCTIVE_TOKENS) && !has(ADDITIVE_TOKENS));
120
+ const openWorldHint = openWorld.test(name);
121
+ if (readOnly) {
122
+ return { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint };
123
+ }
124
+ const additive = nonDestructiveExplicit.has(name) || (has(ADDITIVE_TOKENS) && !has(DESTRUCTIVE_TOKENS));
125
+ return {
126
+ readOnlyHint: false,
127
+ destructiveHint: !additive,
128
+ idempotentHint: !has(NOT_IDEMPOTENT_TOKENS),
129
+ openWorldHint,
130
+ };
128
131
  }
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
132
 
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
- };
133
+ // Returns the tool with `title` and `annotations` filled in; a hand-written value on the tool wins.
134
+ function withAnnotations(tool) {
135
+ return {
136
+ ...tool,
137
+ title: tool.title ?? titleFor(tool.name),
138
+ annotations: { ...annotationsFor(tool.name), ...(tool.annotations ?? {}) },
139
+ };
140
+ }
141
+
142
+ return { annotationsFor, withAnnotations };
145
143
  }
144
+
145
+ const BASE = makeAnnotator();
146
+ export const annotationsFor = BASE.annotationsFor;
147
+ 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
- }