@moda-ai/cli 1.8.1 → 1.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/auth.js CHANGED
@@ -312,12 +312,44 @@ export async function authFetch(path, options = {}, authOptions = {}) {
312
312
  if (!token) {
313
313
  throw new Error('Not authenticated. Run "moda init" to log in.');
314
314
  }
315
- const url = `${resolveModaBaseUrl()}${path}`;
316
315
  const headers = new Headers(options.headers);
317
316
  headers.set('Authorization', `Bearer ${token}`);
318
317
  if (!headers.has('Content-Type') && options.body) {
319
318
  headers.set('Content-Type', 'application/json');
320
319
  }
321
- return fetch(url, { ...options, headers });
320
+ const origin = new URL(resolveModaBaseUrl()).origin;
321
+ let url = `${resolveModaBaseUrl()}${path}`;
322
+ // Follow redirects manually so EVERY hop is origin-checked, not just the
323
+ // first. Native `redirect: 'follow'` would silently strip the Authorization
324
+ // header on a cross-host hop (per the Fetch spec) — the exact failure that
325
+ // broke moda init (the legacy www → apex 308). Same-host hops keep the header
326
+ // and are safe to follow; any hop that leaves the Moda origin fails fast with
327
+ // an actionable error instead of surfacing as a confusing 401.
328
+ const MAX_REDIRECTS = 5;
329
+ for (let hop = 0;; hop++) {
330
+ const response = await fetch(url, { ...options, headers, redirect: 'manual' });
331
+ if (response.status < 300 || response.status >= 400) {
332
+ return response;
333
+ }
334
+ const location = response.headers.get('location');
335
+ const target = location ? safeResolveUrl(location, url) : null;
336
+ if (!target || target.origin !== origin) {
337
+ throw new Error(`Moda API redirected (${response.status}) to ${location ?? '(unknown)'}. ` +
338
+ 'Cross-host redirects strip Authorization and break moda init. ' +
339
+ 'Use MODA_BASE_URL=https://moda.dev (not www.moda.dev).');
340
+ }
341
+ if (hop >= MAX_REDIRECTS) {
342
+ throw new Error(`Moda API exceeded ${MAX_REDIRECTS} same-host redirects for ${path}.`);
343
+ }
344
+ url = target.toString();
345
+ }
346
+ }
347
+ function safeResolveUrl(location, base) {
348
+ try {
349
+ return new URL(location, base);
350
+ }
351
+ catch {
352
+ return null;
353
+ }
322
354
  }
323
355
  //# sourceMappingURL=auth.js.map
package/dist/cli.js CHANGED
@@ -13,7 +13,7 @@ import { runHarnessCommand } from './harness.js';
13
13
  import { runSyncCommand } from './sync.js';
14
14
  import { runDoctorCommand, runStatusCommand } from './doctor.js';
15
15
  import { CliInputError } from './errors.js';
16
- import { isTelemetryDisabled, sendCliUsageTelemetry, stripTelemetryFlag, } from './telemetry.js';
16
+ import { buildSearchAdoptionTelemetry, isTelemetryDisabled, SEARCH_MODES, sendCliUsageTelemetry, stripTelemetryFlag, } from './telemetry.js';
17
17
  import { isUpdateCheckDisabled, printUpdateNotice, startUpdateCheck, stripUpdateCheckFlag, } from './update-check.js';
18
18
  import { createCommandContext } from './command-context.js';
19
19
  import { createCommandRegistry } from './command-registry.js';
@@ -336,6 +336,44 @@ async function attachWorldState(rows) {
336
336
  }
337
337
  }));
338
338
  }
339
+ /**
340
+ * Record response-derived search telemetry from a `/search` payload. The
341
+ * endpoint returns the effective `search_mode` (which may differ from the
342
+ * requested mode after a degrade) plus a first-class `pagination.has_more`
343
+ * (the service fetches a candidate pool larger than the page, so this is a real
344
+ * signal — not a fragile "page filled the limit" heuristic).
345
+ */
346
+ function recordSearchTelemetry(context, data) {
347
+ const record = asRecord(data);
348
+ if (!record)
349
+ return;
350
+ const results = Array.isArray(record.results) ? record.results : [];
351
+ const effective = typeof record.search_mode === 'string'
352
+ ? record.search_mode.toLowerCase()
353
+ : undefined;
354
+ const pagination = asRecord(record.pagination);
355
+ const hasMore = pagination?.has_more;
356
+ context.recordTelemetry({
357
+ search_mode_effective: SEARCH_MODES.includes(effective)
358
+ ? effective
359
+ : undefined,
360
+ result_count: results.length,
361
+ has_more: hasMore === true ? 'yes' : hasMore === false ? 'no' : 'na',
362
+ });
363
+ }
364
+ /** Record response-derived telemetry from a `/conversations` list payload. */
365
+ function recordConversationsTelemetry(context, data) {
366
+ const record = asRecord(data);
367
+ if (!record)
368
+ return;
369
+ const list = Array.isArray(record.conversations) ? record.conversations : [];
370
+ const pagination = asRecord(record.pagination);
371
+ const hasMore = pagination?.has_more;
372
+ context.recordTelemetry({
373
+ result_count: list.length,
374
+ has_more: hasMore === true ? 'yes' : hasMore === false ? 'no' : 'na',
375
+ });
376
+ }
339
377
  export async function runCommand(command, positional, flags, positionals = positional ? [positional] : [], context = createCommandContext({ command, positional, positionals, flags })) {
340
378
  switch (command) {
341
379
  case 'prompts': {
@@ -411,6 +449,7 @@ export async function runCommand(command, positional, flags, positionals = posit
411
449
  query.set('offset', params.offset.toString());
412
450
  const queryString = query.toString() ? `?${query.toString()}` : '';
413
451
  const data = await callDataAPI(`/conversations${queryString}`);
452
+ recordConversationsTelemetry(context, data);
414
453
  if (params.include_world_state === true) {
415
454
  const enriched = asRecord(data) ?? {};
416
455
  const list = Array.isArray(enriched.conversations)
@@ -444,6 +483,7 @@ export async function runCommand(command, positional, flags, positionals = posit
444
483
  if (params.limit)
445
484
  query.set('limit', params.limit.toString());
446
485
  const data = await callDataAPI(`/search?${query.toString()}`);
486
+ recordSearchTelemetry(context, data);
447
487
  context.output.writeData(data);
448
488
  break;
449
489
  }
@@ -1145,6 +1185,14 @@ export async function dispatchParsedCommand(parsed, startedAt = Date.now()) {
1145
1185
  exitCode: result.exitCode,
1146
1186
  durationMs: Date.now() - startedAt,
1147
1187
  apiRequestCount: getApiRequestCount(),
1188
+ adoption: buildSearchAdoptionTelemetry({
1189
+ command: definition.name,
1190
+ positional: parsed.positional,
1191
+ flags: commandFlags,
1192
+ outputMode: context.outputMode,
1193
+ recorded: context.getTelemetryMeta(),
1194
+ skillVersion: process.env.MODA_SKILL_VERSION,
1195
+ }),
1148
1196
  });
1149
1197
  }
1150
1198
  if (!updateCheckDisabled) {
@@ -1163,6 +1211,14 @@ export async function dispatchParsedCommand(parsed, startedAt = Date.now()) {
1163
1211
  exitCode: 1,
1164
1212
  durationMs: Date.now() - startedAt,
1165
1213
  apiRequestCount: getApiRequestCount(),
1214
+ adoption: buildSearchAdoptionTelemetry({
1215
+ command: definition.name,
1216
+ positional: parsed.positional,
1217
+ flags: commandFlags,
1218
+ outputMode: context.outputMode,
1219
+ recorded: context.getTelemetryMeta(),
1220
+ skillVersion: process.env.MODA_SKILL_VERSION,
1221
+ }),
1166
1222
  });
1167
1223
  }
1168
1224
  if (context.outputMode === 'agent' || context.outputMode === 'agent-stream') {
@@ -5,6 +5,17 @@ export interface CommandContextInput {
5
5
  positionals: string[];
6
6
  flags: Record<string, string>;
7
7
  }
8
+ /**
9
+ * Response-derived telemetry a handler can surface back to the dispatcher after
10
+ * the API call completes. Static fields (surface, requested mode, query length,
11
+ * etc.) are derived from the command + flags in the dispatcher and don't belong
12
+ * here — only values that require the runtime response are recorded.
13
+ */
14
+ export interface CommandTelemetryMeta {
15
+ search_mode_effective?: 'na' | 'keyword' | 'semantic' | 'hybrid';
16
+ result_count?: number;
17
+ has_more?: 'na' | 'no' | 'yes';
18
+ }
8
19
  export interface CommandContext {
9
20
  command: string;
10
21
  positional: string | undefined;
@@ -22,6 +33,10 @@ export interface CommandContext {
22
33
  quiet: boolean;
23
34
  verbose: boolean;
24
35
  interactive: boolean;
36
+ /** Merge response-derived telemetry; safe to call multiple times. */
37
+ recordTelemetry(meta: Partial<CommandTelemetryMeta>): void;
38
+ /** Snapshot of telemetry recorded so far by the handler. */
39
+ getTelemetryMeta(): CommandTelemetryMeta;
25
40
  }
26
41
  export declare function createCommandContext(input: CommandContextInput, options?: {
27
42
  commandName?: string;
@@ -12,6 +12,7 @@ export function createCommandContext(input, options = {}) {
12
12
  !flagEnabled(flags, 'yes') &&
13
13
  process.stdin.isTTY === true &&
14
14
  process.stderr.isTTY === true;
15
+ const telemetryMeta = {};
15
16
  return {
16
17
  command: options.commandName ?? input.command,
17
18
  positional: input.positional,
@@ -33,6 +34,12 @@ export function createCommandContext(input, options = {}) {
33
34
  quiet,
34
35
  verbose: flagEnabled(flags, 'verbose'),
35
36
  interactive,
37
+ recordTelemetry(meta) {
38
+ Object.assign(telemetryMeta, meta);
39
+ },
40
+ getTelemetryMeta() {
41
+ return telemetryMeta;
42
+ },
36
43
  };
37
44
  }
38
45
  //# sourceMappingURL=command-context.js.map
package/dist/config.d.ts CHANGED
@@ -27,7 +27,8 @@ export declare function saveCliConfig(update: CliConfig): void;
27
27
  export declare function resolveApiKey(options?: ProfileIOOptions): string | undefined;
28
28
  /**
29
29
  * Resolve the ingest worker base URL used by `moda workspace snapshot`.
30
- * Env override -> persisted config -> production default.
30
+ * Env override -> active profile -> legacy config -> production default.
31
+ * Loopback URLs are rejected unless the active profile is `local`.
31
32
  */
32
33
  export declare function resolveIngestUrl(options?: ProfileIOOptions): string;
33
34
  /**
package/dist/config.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import { readFileSync } from 'node:fs';
2
- import { DEFAULT_INGEST_URL, PROD_BASE_URL, getLegacyConfigPath, loadLegacyCliConfig, resolveApiKeyFromActiveProfile, resolveBaseUrl, resolveIngestUrlFromActiveProfile, resolveTenantIdFromActiveProfile, syncLegacyConfigToProfile, writeLegacyCliConfig, } from './profiles.js';
2
+ import { DEFAULT_INGEST_URL, PROD_BASE_URL, getLegacyConfigPath, loadLegacyCliConfig, resolveApiKeyFromActiveProfile, resolveBaseUrl, resolveIngestUrl as resolveProfileIngestUrl, resolveTenantIdFromActiveProfile, syncLegacyConfigToProfile, writeLegacyCliConfig, } from './profiles.js';
3
3
  const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
4
4
  export const VERSION = pkg.version;
5
- // Canonical host is www.moda.dev. The apex `moda.dev` 308-redirects to it, and
5
+ // Canonical host is moda.dev. The www host 308-redirects to the apex, and
6
6
  // cross-host redirects strip the Authorization header (fetch/undici), so using
7
- // the apex breaks `moda init`'s authenticated calls with a 401. Default to the
8
- // canonical host directly.
7
+ // www breaks `moda init`'s authenticated calls with a 401. Default to the
8
+ // canonical host directly; resolveBaseUrl() also rewrites legacy www URLs.
9
9
  export const MODA_BASE_URL = resolveBaseUrl();
10
10
  export function loadCliConfig(options = {}) {
11
11
  return loadLegacyCliConfig(options);
@@ -38,14 +38,11 @@ export function resolveApiKey(options = {}) {
38
38
  }
39
39
  /**
40
40
  * Resolve the ingest worker base URL used by `moda workspace snapshot`.
41
- * Env override -> persisted config -> production default.
41
+ * Env override -> active profile -> legacy config -> production default.
42
+ * Loopback URLs are rejected unless the active profile is `local`.
42
43
  */
43
44
  export function resolveIngestUrl(options = {}) {
44
- const env = options.env ?? process.env;
45
- return (env.MODA_INGEST_URL ||
46
- resolveIngestUrlFromActiveProfile(options) ||
47
- loadCliConfig(options).ingest_url ||
48
- DEFAULT_INGEST_URL);
45
+ return resolveProfileIngestUrl(options);
49
46
  }
50
47
  /**
51
48
  * Resolve the active tenant id: env override first, then the persisted
@@ -1,5 +1,5 @@
1
1
  export declare const PROFILE_CONFIG_VERSION = 1;
2
- export declare const PROD_BASE_URL = "https://www.moda.dev";
2
+ export declare const PROD_BASE_URL = "https://moda.dev";
3
3
  export declare const DEFAULT_INGEST_URL = "https://moda-ingest.modas.workers.dev";
4
4
  export declare const DEFAULT_PROFILE_NAME = "prod";
5
5
  export type ModaEnvironment = 'local' | 'dev' | 'staging' | 'prod';
@@ -71,6 +71,7 @@ export declare function getLegacyConfigPath(options?: ProfileIOOptions): string;
71
71
  export declare function loadLegacyCliConfig(options?: ProfileIOOptions): LegacyCliConfig;
72
72
  export declare function writeLegacyCliConfig(update: LegacyCliConfig, options?: ProfileIOOptions): LegacyCliConfig;
73
73
  export declare function createDefaultProfiles(options?: ProfileIOOptions): Record<string, ModaProfile>;
74
+ export declare function isLoopbackBaseUrl(url: string): boolean;
74
75
  export declare function assertValidProfileName(name: string): void;
75
76
  export declare function inspectProfileMigrationState(options?: ProfileIOOptions): ProfileMigrationState;
76
77
  export declare function loadProfileConfig(options?: ProfileIOOptions): ProfileConfig;
@@ -82,7 +83,14 @@ export declare function syncLegacyConfigToProfile(legacy: LegacyCliConfig, optio
82
83
  export declare function secretFilePathFromRef(reference: SecretReference, options?: ProfileIOOptions): string | undefined;
83
84
  export declare function resolveSecretReference(reference: SecretReference | undefined, options?: ProfileIOOptions): string | undefined;
84
85
  export declare function resolveApiKeyFromActiveProfile(options?: ProfileIOOptions): string | undefined;
86
+ /**
87
+ * Vercel serves moda.dev as canonical and 308-redirects www to the apex. Node
88
+ * fetch strips Authorization on cross-host redirects, which breaks moda init
89
+ * step 3 when the base URL still points at www.
90
+ */
91
+ export declare function normalizeModaBaseUrl(url: string): string;
85
92
  export declare function resolveBaseUrl(options?: ProfileIOOptions): string;
93
+ export declare function resolveIngestUrl(options?: ProfileIOOptions): string;
86
94
  export declare function resolveIngestUrlFromActiveProfile(options?: ProfileIOOptions): string | undefined;
87
95
  export declare function resolveTenantIdFromActiveProfile(options?: ProfileIOOptions): string | undefined;
88
96
  export declare function ensureSecretFilePermissions(path: string, repair?: boolean): SecretFilePermissionStatus;
package/dist/profiles.js CHANGED
@@ -2,7 +2,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync
2
2
  import { homedir } from 'node:os';
3
3
  import { dirname, isAbsolute, join, normalize, relative } from 'node:path';
4
4
  export const PROFILE_CONFIG_VERSION = 1;
5
- export const PROD_BASE_URL = 'https://www.moda.dev';
5
+ export const PROD_BASE_URL = 'https://moda.dev';
6
6
  export const DEFAULT_INGEST_URL = 'https://moda-ingest.modas.workers.dev';
7
7
  export const DEFAULT_PROFILE_NAME = 'prod';
8
8
  function getEnv(options = {}) {
@@ -93,6 +93,65 @@ export function createDefaultProfiles(options = {}) {
93
93
  prod: profileDefaults('prod', 'prod', now),
94
94
  };
95
95
  }
96
+ function isLoopbackHostname(hostname) {
97
+ const host = hostname.toLowerCase();
98
+ return (host === 'localhost' ||
99
+ host === '127.0.0.1' ||
100
+ host === '::1' ||
101
+ host === '[::1]');
102
+ }
103
+ export function isLoopbackBaseUrl(url) {
104
+ try {
105
+ return isLoopbackHostname(new URL(url).hostname);
106
+ }
107
+ catch {
108
+ return false;
109
+ }
110
+ }
111
+ function defaultBaseUrlForEnvironment(environment) {
112
+ if (environment === 'local')
113
+ return 'http://localhost:3000';
114
+ if (environment === 'staging')
115
+ return 'https://staging.moda.dev';
116
+ return PROD_BASE_URL;
117
+ }
118
+ function defaultIngestUrlForEnvironment(environment) {
119
+ if (environment === 'local')
120
+ return 'http://localhost:8787';
121
+ return DEFAULT_INGEST_URL;
122
+ }
123
+ function environmentAllowsLoopback(environment) {
124
+ return environment !== 'prod';
125
+ }
126
+ function sanitizeProfileBaseUrl(url, environment) {
127
+ const normalized = normalizeModaBaseUrl(url);
128
+ if (environmentAllowsLoopback(environment) || !isLoopbackBaseUrl(normalized)) {
129
+ return normalized;
130
+ }
131
+ return PROD_BASE_URL;
132
+ }
133
+ function sanitizeProfileIngestUrl(url, environment) {
134
+ if (!url)
135
+ return url;
136
+ if (environmentAllowsLoopback(environment) || !isLoopbackBaseUrl(url)) {
137
+ return url;
138
+ }
139
+ return DEFAULT_INGEST_URL;
140
+ }
141
+ function enforceNonLoopbackBaseUrl(url, environment) {
142
+ return sanitizeProfileBaseUrl(url, environment);
143
+ }
144
+ function enforceNonLoopbackIngestUrl(url, environment) {
145
+ return sanitizeProfileIngestUrl(url, environment) ?? defaultIngestUrlForEnvironment(environment);
146
+ }
147
+ function activeProfileEnvironment(options) {
148
+ try {
149
+ return getActiveProfile(options).environment;
150
+ }
151
+ catch {
152
+ return 'prod';
153
+ }
154
+ }
96
155
  function normalizeEnvironment(value) {
97
156
  if (value === 'local' ||
98
157
  value === 'dev' ||
@@ -131,8 +190,8 @@ function normalizeProfile(fallbackName, value, options) {
131
190
  const defaults = profileDefaults(name, normalizeEnvironment(value.environment), options.now ?? new Date());
132
191
  return {
133
192
  ...defaults,
134
- base_url: optionalString(value.base_url) ?? defaults.base_url,
135
- ingest_url: optionalString(value.ingest_url) ?? defaults.ingest_url,
193
+ base_url: sanitizeProfileBaseUrl(optionalString(value.base_url) ?? defaults.base_url, normalizeEnvironment(value.environment)),
194
+ ingest_url: sanitizeProfileIngestUrl(optionalString(value.ingest_url) ?? defaults.ingest_url, normalizeEnvironment(value.environment)),
136
195
  tenant_id: optionalString(value.tenant_id),
137
196
  workspace_id: optionalString(value.workspace_id),
138
197
  api_key_ref: normalizeSecretReference(value.api_key_ref),
@@ -170,9 +229,12 @@ function applyLegacyAwareness(config, options) {
170
229
  const migration = inspectProfileMigrationState(options);
171
230
  const prod = config.profiles.prod;
172
231
  if (prod && (legacy.api_key || legacy.ingest_url || legacy.tenant_id)) {
232
+ const migratedIngestUrl = legacy.ingest_url && !isLoopbackBaseUrl(legacy.ingest_url)
233
+ ? legacy.ingest_url
234
+ : prod.ingest_url;
173
235
  config.profiles.prod = {
174
236
  ...prod,
175
- ingest_url: legacy.ingest_url ?? prod.ingest_url,
237
+ ingest_url: migratedIngestUrl,
176
238
  tenant_id: legacy.tenant_id ?? prod.tenant_id,
177
239
  api_key_ref: legacy.api_key ? 'legacy:config.api_key' : prod.api_key_ref,
178
240
  auth_token_ref: migration.legacy_auth.has_access_token
@@ -343,17 +405,58 @@ export function resolveApiKeyFromActiveProfile(options = {}) {
343
405
  return undefined;
344
406
  }
345
407
  }
408
+ /**
409
+ * Vercel serves moda.dev as canonical and 308-redirects www to the apex. Node
410
+ * fetch strips Authorization on cross-host redirects, which breaks moda init
411
+ * step 3 when the base URL still points at www.
412
+ */
413
+ export function normalizeModaBaseUrl(url) {
414
+ try {
415
+ const parsed = new URL(url);
416
+ if (parsed.hostname === 'www.moda.dev') {
417
+ parsed.hostname = 'moda.dev';
418
+ return parsed.toString().replace(/\/$/, '');
419
+ }
420
+ }
421
+ catch {
422
+ // Not a valid URL; return unchanged and let the caller surface the error.
423
+ }
424
+ return url.replace(/\/$/, '');
425
+ }
346
426
  export function resolveBaseUrl(options = {}) {
427
+ const environment = activeProfileEnvironment(options);
347
428
  const envBase = optionalString(getEnv(options).MODA_BASE_URL);
348
- if (envBase)
349
- return envBase;
429
+ if (envBase) {
430
+ return enforceNonLoopbackBaseUrl(normalizeModaBaseUrl(envBase), environment);
431
+ }
350
432
  try {
351
- return getActiveProfile(options).base_url || PROD_BASE_URL;
433
+ return enforceNonLoopbackBaseUrl(normalizeModaBaseUrl(getActiveProfile(options).base_url || PROD_BASE_URL), environment);
352
434
  }
353
435
  catch {
354
436
  return PROD_BASE_URL;
355
437
  }
356
438
  }
439
+ export function resolveIngestUrl(options = {}) {
440
+ const environment = activeProfileEnvironment(options);
441
+ const envIngest = optionalString(getEnv(options).MODA_INGEST_URL);
442
+ if (envIngest) {
443
+ return enforceNonLoopbackIngestUrl(envIngest, environment);
444
+ }
445
+ try {
446
+ const profileIngest = getActiveProfile(options).ingest_url;
447
+ if (profileIngest) {
448
+ return enforceNonLoopbackIngestUrl(profileIngest, environment);
449
+ }
450
+ }
451
+ catch {
452
+ // Fall through to legacy/default below.
453
+ }
454
+ const legacyIngest = optionalString(loadLegacyCliConfig(options).ingest_url);
455
+ if (legacyIngest) {
456
+ return enforceNonLoopbackIngestUrl(legacyIngest, environment);
457
+ }
458
+ return defaultIngestUrlForEnvironment(environment);
459
+ }
357
460
  export function resolveIngestUrlFromActiveProfile(options = {}) {
358
461
  try {
359
462
  return getActiveProfile(options).ingest_url;
@@ -1,5 +1,28 @@
1
+ import type { CliOutputMode } from './output.js';
2
+ import type { CommandTelemetryMeta } from './command-context.js';
1
3
  export type TelemetryStatus = 'success' | 'error';
2
4
  export type TelemetryErrorType = 'validation_error' | 'api_error' | 'network_error' | 'unknown_error';
5
+ export type SearchSurface = 'none' | 'conversation' | 'world_state' | 'cluster' | 'message' | 'context';
6
+ export type SearchMode = 'na' | 'keyword' | 'semantic' | 'hybrid';
7
+ export type Tristate = 'na' | 'no' | 'yes';
8
+ /**
9
+ * Phase 6 search-adoption telemetry. Every field is optional and the backend
10
+ * defaults absent values, so it is always safe to omit a field we can't derive.
11
+ */
12
+ export interface SearchAdoptionTelemetry {
13
+ output_mode?: CliOutputMode;
14
+ subcommand?: string;
15
+ search_surface?: SearchSurface;
16
+ search_mode_requested?: SearchMode;
17
+ search_mode_effective?: SearchMode;
18
+ query_length?: number;
19
+ query_term_count?: number;
20
+ result_count?: number;
21
+ zero_result?: Tristate;
22
+ has_more?: Tristate;
23
+ used_msg_index?: 0 | 1;
24
+ skill_version?: string;
25
+ }
3
26
  export interface CliUsageTelemetryInput {
4
27
  apiKey: string | undefined;
5
28
  command: string;
@@ -9,7 +32,22 @@ export interface CliUsageTelemetryInput {
9
32
  exitCode: number;
10
33
  durationMs: number;
11
34
  apiRequestCount: number;
35
+ adoption?: SearchAdoptionTelemetry;
12
36
  }
37
+ export declare const SEARCH_MODES: readonly SearchMode[];
38
+ /**
39
+ * Derive the search-adoption telemetry for a command. Static fields come from
40
+ * the command name + flags; response-derived fields (effective mode, result
41
+ * count, has_more) are read from whatever the handler recorded on the context.
42
+ */
43
+ export declare function buildSearchAdoptionTelemetry(input: {
44
+ command: string;
45
+ positional: string | undefined;
46
+ flags: Record<string, string>;
47
+ outputMode: CliOutputMode;
48
+ recorded: CommandTelemetryMeta;
49
+ skillVersion?: string;
50
+ }): SearchAdoptionTelemetry;
13
51
  export declare function stripTelemetryFlag(flags: Record<string, string>): Record<string, string>;
14
52
  export declare function isTelemetryDisabled(flags: Record<string, string>): boolean;
15
53
  export declare function getInvocationId(): string;
package/dist/telemetry.js CHANGED
@@ -3,6 +3,109 @@ import { VERSION, resolveModaBaseUrl } from './config.js';
3
3
  const TELEMETRY_TIMEOUT_MS = 500;
4
4
  const INVOCATION_ID = randomUUID();
5
5
  const TELEMETRY_FLAG = 'no-telemetry';
6
+ const SEARCH_SURFACE_BY_COMMAND = {
7
+ search: 'message',
8
+ conversations: 'conversation',
9
+ clusters: 'cluster',
10
+ 'cluster-conversations': 'cluster',
11
+ 'world-state': 'world_state',
12
+ context: 'context',
13
+ };
14
+ export const SEARCH_MODES = ['keyword', 'semantic', 'hybrid'];
15
+ function termCount(query) {
16
+ return query.trim().split(/\s+/).filter(Boolean).length;
17
+ }
18
+ function clampInt(value, min, max) {
19
+ if (typeof value !== 'number' || !Number.isFinite(value))
20
+ return undefined;
21
+ return Math.min(max, Math.max(min, Math.round(value)));
22
+ }
23
+ /**
24
+ * Derive the search-adoption telemetry for a command. Static fields come from
25
+ * the command name + flags; response-derived fields (effective mode, result
26
+ * count, has_more) are read from whatever the handler recorded on the context.
27
+ */
28
+ export function buildSearchAdoptionTelemetry(input) {
29
+ const { command, positional, flags, outputMode, recorded } = input;
30
+ const adoption = {
31
+ output_mode: outputMode,
32
+ search_surface: SEARCH_SURFACE_BY_COMMAND[command] ?? 'none',
33
+ };
34
+ // The query lives in the positional for `moda search`, but in `--search` for
35
+ // the conversations list surface.
36
+ let queryText;
37
+ if (command === 'search')
38
+ queryText = positional;
39
+ else if (command === 'conversations')
40
+ queryText = flags.search;
41
+ // Trim before measuring both dimensions so query_length and query_term_count
42
+ // describe the same (trimmed) query the backend actually sees.
43
+ const trimmedQuery = queryText?.trim() ?? '';
44
+ if (trimmedQuery !== '') {
45
+ adoption.query_length = trimmedQuery.length;
46
+ adoption.query_term_count = termCount(trimmedQuery);
47
+ }
48
+ // search_mode is only meaningful for the message-grain search command.
49
+ if (command === 'search') {
50
+ const requested = (flags.mode ?? '').toLowerCase();
51
+ adoption.search_mode_requested = SEARCH_MODES.includes(requested)
52
+ ? requested
53
+ : 'hybrid'; // mirrors SearchSchema's default
54
+ }
55
+ // used_msg_index only applies to the conversation context surface.
56
+ if (command === 'context') {
57
+ adoption.used_msg_index = flags['msg-index'] !== undefined ? 1 : 0;
58
+ }
59
+ if (recorded.search_mode_effective) {
60
+ adoption.search_mode_effective = recorded.search_mode_effective;
61
+ }
62
+ if (typeof recorded.result_count === 'number') {
63
+ adoption.result_count = recorded.result_count;
64
+ adoption.zero_result = recorded.result_count === 0 ? 'yes' : 'no';
65
+ }
66
+ if (recorded.has_more)
67
+ adoption.has_more = recorded.has_more;
68
+ const skill = input.skillVersion?.trim();
69
+ if (skill)
70
+ adoption.skill_version = skill.slice(0, 64);
71
+ return adoption;
72
+ }
73
+ function serializeAdoption(adoption) {
74
+ if (!adoption)
75
+ return {};
76
+ const out = {};
77
+ if (adoption.output_mode)
78
+ out.output_mode = adoption.output_mode;
79
+ if (adoption.subcommand)
80
+ out.subcommand = adoption.subcommand.slice(0, 64);
81
+ if (adoption.search_surface)
82
+ out.search_surface = adoption.search_surface;
83
+ if (adoption.search_mode_requested) {
84
+ out.search_mode_requested = adoption.search_mode_requested;
85
+ }
86
+ if (adoption.search_mode_effective) {
87
+ out.search_mode_effective = adoption.search_mode_effective;
88
+ }
89
+ const queryLength = clampInt(adoption.query_length, 0, 2000);
90
+ if (queryLength !== undefined)
91
+ out.query_length = queryLength;
92
+ const queryTermCount = clampInt(adoption.query_term_count, 0, 200);
93
+ if (queryTermCount !== undefined)
94
+ out.query_term_count = queryTermCount;
95
+ const resultCount = clampInt(adoption.result_count, 0, 100_000);
96
+ if (resultCount !== undefined)
97
+ out.result_count = resultCount;
98
+ if (adoption.zero_result)
99
+ out.zero_result = adoption.zero_result;
100
+ if (adoption.has_more)
101
+ out.has_more = adoption.has_more;
102
+ if (adoption.used_msg_index !== undefined) {
103
+ out.used_msg_index = adoption.used_msg_index ? 1 : 0;
104
+ }
105
+ if (adoption.skill_version)
106
+ out.skill_version = adoption.skill_version.slice(0, 64);
107
+ return out;
108
+ }
6
109
  export function stripTelemetryFlag(flags) {
7
110
  const { [TELEMETRY_FLAG]: _drop, ...rest } = flags;
8
111
  return rest;
@@ -49,6 +152,7 @@ export async function sendCliUsageTelemetry(input) {
49
152
  platform: process.platform,
50
153
  arch: process.arch,
51
154
  flags_json: { names: sanitizedFlagNames(input.flags) },
155
+ ...serializeAdoption(input.adoption),
52
156
  }),
53
157
  signal: controller.signal,
54
158
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moda-ai/cli",
3
- "version": "1.8.1",
3
+ "version": "1.9.6",
4
4
  "description": "CLI for Moda - AI agent analytics and observability",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,8 +13,7 @@
13
13
  "!dist/__tests__",
14
14
  "hooks/**",
15
15
  "skills/**/SKILL.md",
16
- "README.md",
17
- ".env.example"
16
+ "README.md"
18
17
  ],
19
18
  "publishConfig": {
20
19
  "access": "public"
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: moda-cli
3
- description: Query Moda's AI agent conversation analytics and manage code-first prompt versions from the terminal — overview KPIs, topic clusters, conversation search, message context, user frustration detections, tool failures, agent jobs, recurring workflows, active loop detections, and moda prompts status/sync/promote. Use when the user asks about moda, modaflows, conversation analytics, prompt management, user frustrations, agent observability, tool failure debugging, why an agent run partially completed or looped, or wants to investigate how their AI agent is performing.
3
+ version: 2.0.0
4
+ description: Query Moda's AI agent conversation analytics and manage code-first prompt versions from the terminal — semantic/keyword/hybrid message search, overview KPIs, topic clusters, message context, user frustration detections, tool failures, agent jobs, recurring workflows, active loop detections, and moda prompts status/sync/promote. Use when the user asks about moda, modaflows, conversation analytics, prompt management, user frustrations, agent observability, tool failure debugging, why an agent run partially completed or looped, wants to find conversations or tool calls about a topic, or wants to investigate how their AI agent is performing.
4
5
  ---
5
6
 
6
7
  # Moda CLI
@@ -13,15 +14,26 @@ filter. Analytics commands are read-only. Prompt management includes both
13
14
  read-only commands (`prompts status`, `prompts diff`) and commands that sync
14
15
  or promote remote state (`prompts sync`, `prompts promote`).
15
16
 
17
+ **`moda search` is the primary way to find anything by content.** It runs
18
+ message-grain semantic, keyword, or hybrid retrieval across every message —
19
+ including tool calls and tool results — and returns ranked snippets with
20
+ direct anchors (`conversation_id` + `message_index`) you can hand straight to
21
+ `moda context`. Reach for it first whenever the question is "where did X
22
+ happen?" or "find conversations/tool calls about Y". Use `moda conversations`
23
+ only when you need to *list/filter* by structured fields (cluster, user,
24
+ environment, outcome), not to search by meaning.
25
+
16
26
  ## When to use
17
27
 
18
28
  Activate this skill when the user wants to:
19
29
 
30
+ - **Find conversations or tool calls about a topic, error, or behavior**
31
+ (semantic/keyword/hybrid search) — start with `moda search`
20
32
  - Inspect agent health: conversation volume, frustration rate, tool failures
21
33
  - Debug a specific frustrated conversation
22
34
  - Investigate which tools are failing and why
23
35
  - Browse conversation topics (clusters)
24
- - Search conversations by content, user, environment, or time range
36
+ - List/filter conversations by user, environment, cluster, outcome, or time
25
37
  - Manage code-first prompt versions when the user explicitly asks for prompt
26
38
  sync, prompt status, or prompt promotion
27
39
 
@@ -41,6 +53,9 @@ Optional:
41
53
 
42
54
  - `MODA_BASE_URL` defaults to `https://moda.dev`; override only for
43
55
  self-hosted or staging.
56
+ - `MODA_SKILL_VERSION` — export to `2.0.0` so search-adoption telemetry can
57
+ attribute usage to this skill version. Set it once per session:
58
+ `export MODA_SKILL_VERSION=2.0.0`.
44
59
 
45
60
  ## Setup
46
61
 
@@ -65,10 +80,63 @@ export MODA_API_KEY="moda_sk_..." # from moda.dev/settings
65
80
 
66
81
  ## Procedure
67
82
 
68
- Default investigation pattern: **broad → narrow → context**. Always start with
69
- `moda overview` to ground subsequent calls in real numbers.
83
+ Two entry points depending on the question:
84
+
85
+ - **"Find / where did X happen?"** → start with `moda search` (semantic,
86
+ keyword, or hybrid retrieval over every message), then `moda context` on the
87
+ returned anchors.
88
+ - **"How healthy is the agent overall?"** → start with `moda overview`, then
89
+ drill down (clusters → conversations → context).
90
+
91
+ Default investigation pattern for content questions: **search → context**.
92
+ For health questions: **broad → narrow → context**.
93
+
94
+ ### 1. Search messages (primary content-discovery path)
95
+
96
+ ```bash
97
+ moda search "refund flow timeout" # hybrid (default)
98
+ moda search "customer asked for a refund" --mode=semantic
99
+ moda search "SEARCH_NO_RESULTS" --mode=keyword
100
+ moda search "checkout error" --time-range=7d --limit=10
101
+ moda search "stripe.charges.create failed" --user-id=<id>
102
+ ```
103
+
104
+ Searches conversation messages at message grain, **including tool calls and
105
+ tool results** (tool name, input arguments, and output previews are embedded),
106
+ so it finds where an agent *did* something, not just where it talked about it.
107
+
108
+ Flags: `--mode=keyword|semantic|hybrid` (default `hybrid`), `--user-id`,
109
+ `--time-range` (`all|1h|3d|7d|24h|30d|90d`, default `all`), `--limit` (1–100,
110
+ default 20). The query is a positional arg (max 500 chars).
111
+
112
+ Choosing a mode:
113
+
114
+ - `hybrid` (default) — best general recall; fuses keyword + semantic. Use it
115
+ unless you have a reason not to.
116
+ - `keyword` — exact substring/identifier matches (error codes, tool names,
117
+ IDs). `score` is the integer match count.
118
+ - `semantic` — meaning-based; finds paraphrases the user never typed
119
+ verbatim. `score` is cosine similarity.
120
+
121
+ Each result row carries `conversation_id`, `message_index`, `role`,
122
+ `timestamp`, `snippet`, `score`, and `cluster_id` / `cluster_name`. The
123
+ response also reports `search_mode` — the mode that **actually ran**; a
124
+ `semantic`/`hybrid` request transparently degrades to `keyword` when no
125
+ embedding provider is reachable, so check it before trusting semantic ranking.
126
+ `score` scales differ per mode (see above) — branch on `search_mode`, never
127
+ compare scores across modes.
128
+
129
+ Hand the anchors straight to `moda context`:
70
130
 
71
- ### 1. Overview (always start here)
131
+ ```bash
132
+ # Find, then read the exact turn
133
+ hit=$(moda search "double charge" --limit=1 | jq -r '.results[0]')
134
+ cid=$(echo "$hit" | jq -r '.conversation_id')
135
+ idx=$(echo "$hit" | jq -r '.message_index')
136
+ moda context "$cid" --msg-index="$idx"
137
+ ```
138
+
139
+ ### 2. Overview (start here for health questions)
72
140
 
73
141
  ```bash
74
142
  moda overview # last 7 days
@@ -78,7 +146,7 @@ moda overview --days-back=30
78
146
  Returns total conversations, trend, frustration rate, tool failure summary,
79
147
  top clusters, recent activity.
80
148
 
81
- ### 2. Browse topic clusters
149
+ ### 3. Browse topic clusters
82
150
 
83
151
  ```bash
84
152
  moda clusters # root-level
@@ -86,15 +154,21 @@ moda clusters --parent-id=<node_id> # drill in
86
154
  moda cluster-conversations <node_id> # conversations in cluster
87
155
  ```
88
156
 
89
- ### 3. Search conversations
157
+ ### 3b. List / filter conversations (structured, not semantic)
158
+
159
+ Use `moda conversations` to enumerate or filter by structured fields — not to
160
+ search by meaning (use `moda search` for that). The `--search` flag here is a
161
+ plain keyword filter over conversation text.
90
162
 
91
163
  ```bash
92
- export MODA_API_KEY="moda_sk_..." # from moda.dev/settings
164
+ moda conversations --user-id=<id> --time-range=7d
165
+ moda conversations --cluster-id=<node_id> --environment=production
166
+ moda conversations --search="timeout" --limit=20 # keyword filter only
93
167
  ```
94
168
 
95
169
  Filters: `--search`, `--cluster-id`, `--user-id`, `--time-range`
96
170
  (`all|1h|3d|7d|24h|30d|90d`), `--environment`
97
- (`all|development|staging|production`), `--limit`, `--offset`.
171
+ (`all|development|staging|production`), `--outcome`, `--limit`, `--offset`.
98
172
 
99
173
  ### 4. Read a single conversation
100
174
 
@@ -238,6 +312,22 @@ and future evals.
238
312
 
239
313
  ## Common workflow recipes
240
314
 
315
+ ### Find where something happened (search → context)
316
+
317
+ ```bash
318
+ # Semantic: find the moment a user expressed an intent, in their own words
319
+ moda search "user wants to cancel subscription" --mode=semantic --limit=5 \
320
+ | jq -r '.results[] | "\(.conversation_id) @\(.message_index) \(.snippet)"'
321
+
322
+ # Keyword: locate every tool call/result mentioning an exact identifier
323
+ moda search "stripe.refunds.create" --mode=keyword --limit=10
324
+
325
+ # Search, then open the top hit at the exact turn
326
+ moda search "rate limit exceeded" --limit=1 \
327
+ | jq -r '.results[0] | "\(.conversation_id) \(.message_index)"' \
328
+ | xargs -n2 sh -c 'moda context "$0" --msg-index="$1"'
329
+ ```
330
+
241
331
  ### Daily health check
242
332
 
243
333
  ```bash
@@ -332,6 +422,14 @@ A flag value didn't match the schema. Check enums (`time_range` must be
332
422
  Widen the window: try `--days-back=30` or `--time-range=30d`. Confirm with
333
423
  `moda overview` that data exists in the tenant at all.
334
424
 
425
+ **`moda search` returns nothing, or semantic feels like keyword**
426
+ First widen `--time-range` (default is `all`, so an empty result usually means
427
+ the phrasing, not the window). If the response `search_mode` came back
428
+ `keyword` for a `semantic`/`hybrid` request, the embedding provider was
429
+ unreachable and search degraded — retry, or fall back to `--mode=keyword` with
430
+ exact identifiers. For meaning-based queries prefer `--mode=semantic`; for
431
+ exact error codes, tool names, or IDs prefer `--mode=keyword`.
432
+
335
433
  **`moda: command not found`**
336
434
  Either install globally (`npm install -g @moda-ai/cli`) or invoke through
337
435
  npx: `npx -p @moda-ai/cli moda <command>`.
@@ -341,10 +439,11 @@ npx: `npx -p @moda-ai/cli moda <command>`.
341
439
  | Command | Purpose |
342
440
  |---|---|
343
441
  | `moda init` | Browser auth + provision API key + install agent rules |
442
+ | `moda search "<query>"` | **Message-grain semantic/keyword/hybrid search (incl. tool calls); primary content-discovery path** |
344
443
  | `moda overview` | Dashboard KPIs, top clusters, recent activity |
345
444
  | `moda clusters` | Browse topic cluster hierarchy |
346
445
  | `moda cluster-conversations <node_id>` | Conversations in a cluster |
347
- | `moda conversations` | Search/filter conversations |
446
+ | `moda conversations` | List/filter conversations by structured fields |
348
447
  | `moda context <conversation_id>` | Windowed message context (max 5 per side) |
349
448
  | `moda frustrations` | User frustration detections with evidence |
350
449
  | `moda tool-failures` | Tool failure overview |
@@ -359,6 +458,9 @@ npx: `npx -p @moda-ai/cli moda <command>`.
359
458
  | `moda prompts sync` | Upload changed prompt versions and update lockfile |
360
459
  | `moda prompts promote <key>` | Move a remote prompt label |
361
460
 
461
+ `moda search` flags: `--mode=keyword|semantic|hybrid` (default `hybrid`),
462
+ `--user-id`, `--time-range=<range>`, `--limit=N` (1–100, default 20).
463
+
362
464
  Common flags across most commands: `--days-back=N` (1–90), `--limit=N`,
363
465
  `--offset=N`, `--time-range=<range>`. Runs commands also accept
364
466
  `--outcome=<outcome>`, `--class-id=<workflow_class_id>`, and `--user-id=<id>`.
package/.env.example DELETED
@@ -1,8 +0,0 @@
1
- # Moda API Key (required)
2
- # Get this from https://moda.dev/settings
3
- MODA_API_KEY=moda_sk_...
4
-
5
- # Moda Backend URL (optional)
6
- # Defaults to production: https://moda.dev
7
- # For local development, override with:
8
- # MODA_BASE_URL=http://localhost:3001