@moda-ai/cli 1.8.0 → 1.9.0

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/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { z } from 'zod';
3
3
  import { validateConfig, VERSION, resolveApiKey } from './config.js';
4
4
  import { callDataAPI, ApiError, getApiRequestCount, resetApiRequestCount, } from './api-client.js';
5
- import { OverviewSchema, ClustersSchema, ClusterConversationsSchema, ConversationsSchema, WorldStateSchema, ContextSchema, FrustrationsSchema, ToolFailuresSchema, ToolFailureDetailSchema, WorkflowsSchema, RunsSchema, RunDetailSchema, LoopsSchema, LoopDetailSchema, } from './schemas.js';
5
+ import { OverviewSchema, ClustersSchema, ClusterConversationsSchema, ConversationsSchema, SearchSchema, WorldStateSchema, ContextSchema, FrustrationsSchema, ToolFailuresSchema, ToolFailureDetailSchema, WorkflowsSchema, RunsSchema, RunDetailSchema, LoopsSchema, LoopDetailSchema, } from './schemas.js';
6
6
  import { withFrustrationAnchors, withToolFailureAnchors, embeddedWindowSize, asRecord, asString, asNumber } from './transforms.js';
7
7
  import { runPromptsCommand } from './prompts.js';
8
8
  // Coding-agent capture (shadow workspace / Shadow Spark / trace hooks) is temporarily
@@ -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';
@@ -137,6 +137,7 @@ Commands:
137
137
  clusters Browse topic cluster hierarchy
138
138
  cluster-conversations <node_id> List conversations in a cluster
139
139
  conversations Search and filter conversations
140
+ search "<query>" Search conversation messages (keyword/semantic/hybrid)
140
141
  world-state <conversation_id> Get a conversation's world state (slots/threads/events)
141
142
  context <conversation_id> Get windowed conversation context
142
143
  frustrations Get user frustration detections
@@ -192,6 +193,12 @@ Conversations flags:
192
193
  --user-id=ID Filter to a single user
193
194
  --environment=ENV Filter by environment (all|development|staging|production)
194
195
 
196
+ Search flags:
197
+ --mode=keyword|semantic|hybrid Search mode (default hybrid)
198
+ --user-id=ID Filter to a single user
199
+ --time-range=RANGE Time range filter (all|1h|3d|7d|24h|30d|90d)
200
+ --limit=N Maximum results to return (1-100, default 20)
201
+
195
202
  World-state flags:
196
203
  --summary-only Return segment summaries only (omit the event stream)
197
204
  --event-limit=N Max world-state events to return (1-5000, default 2000)
@@ -249,6 +256,8 @@ Examples:
249
256
  moda conversations --search="error" --limit=5
250
257
  moda conversations --world-state="enterprise" --outcome=positive
251
258
  moda conversations --world-state="refund,billing" --include-world-state
259
+ moda search "billing error" --mode=hybrid
260
+ moda search "refund flow" --mode=semantic --time-range=7d --limit=10
252
261
  moda world-state <conversation_id> --summary-only
253
262
  moda frustrations --days-back=14 --limit=20
254
263
  moda context <conversation_id> --window=3
@@ -327,6 +336,44 @@ async function attachWorldState(rows) {
327
336
  }
328
337
  }));
329
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
+ }
330
377
  export async function runCommand(command, positional, flags, positionals = positional ? [positional] : [], context = createCommandContext({ command, positional, positionals, flags })) {
331
378
  switch (command) {
332
379
  case 'prompts': {
@@ -402,6 +449,7 @@ export async function runCommand(command, positional, flags, positionals = posit
402
449
  query.set('offset', params.offset.toString());
403
450
  const queryString = query.toString() ? `?${query.toString()}` : '';
404
451
  const data = await callDataAPI(`/conversations${queryString}`);
452
+ recordConversationsTelemetry(context, data);
405
453
  if (params.include_world_state === true) {
406
454
  const enriched = asRecord(data) ?? {};
407
455
  const list = Array.isArray(enriched.conversations)
@@ -415,6 +463,30 @@ export async function runCommand(command, positional, flags, positionals = posit
415
463
  }
416
464
  break;
417
465
  }
466
+ case 'search': {
467
+ // Positional query mirrors world-state/context/cluster-conversations:
468
+ // reject an empty/whitespace query with the shared CLI input error before
469
+ // it reaches the schema (which also guards via q.min(1)).
470
+ if (!positional || positional.trim() === '') {
471
+ throw new CliInputError('<query> is required', 'Usage: moda search "<query>" [--mode=keyword|semantic|hybrid] [--user-id=ID] [--time-range=RANGE] [--limit=N]');
472
+ }
473
+ const args = flagsToArgs(flags, 'q', positional);
474
+ const params = SearchSchema.parse(args);
475
+ const query = new URLSearchParams();
476
+ query.set('q', params.q);
477
+ if (params.mode)
478
+ query.set('mode', params.mode);
479
+ if (params.user_id)
480
+ query.set('user_id', params.user_id);
481
+ if (params.time_range)
482
+ query.set('time_range', params.time_range);
483
+ if (params.limit)
484
+ query.set('limit', params.limit.toString());
485
+ const data = await callDataAPI(`/search?${query.toString()}`);
486
+ recordSearchTelemetry(context, data);
487
+ context.output.writeData(data);
488
+ break;
489
+ }
418
490
  case 'world-state': {
419
491
  if (!positional) {
420
492
  throw new CliInputError('<conversation_id> is required', 'Usage: moda world-state <conversation_id> [--summary-only] [--event-limit=N]');
@@ -856,6 +928,15 @@ export const commandRegistry = createCommandRegistry([
856
928
  ],
857
929
  ...dataApiDefaults,
858
930
  }),
931
+ legacyCommand({
932
+ name: 'search',
933
+ description: 'Search conversation messages (keyword, semantic, or hybrid)',
934
+ examples: [
935
+ 'moda search "billing error"',
936
+ 'moda search "refund flow" --mode=semantic --time-range=7d --limit=10',
937
+ ],
938
+ ...dataApiDefaults,
939
+ }),
859
940
  legacyCommand({
860
941
  name: 'world-state',
861
942
  description: "Get a conversation's world state (slots, threads, events)",
@@ -1104,6 +1185,14 @@ export async function dispatchParsedCommand(parsed, startedAt = Date.now()) {
1104
1185
  exitCode: result.exitCode,
1105
1186
  durationMs: Date.now() - startedAt,
1106
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
+ }),
1107
1196
  });
1108
1197
  }
1109
1198
  if (!updateCheckDisabled) {
@@ -1122,6 +1211,14 @@ export async function dispatchParsedCommand(parsed, startedAt = Date.now()) {
1122
1211
  exitCode: 1,
1123
1212
  durationMs: Date.now() - startedAt,
1124
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
+ }),
1125
1222
  });
1126
1223
  }
1127
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/schemas.d.ts CHANGED
@@ -141,6 +141,30 @@ export declare const ToolFailureDetailSchema: z.ZodObject<{
141
141
  include_window?: unknown;
142
142
  subtype?: string | undefined;
143
143
  }>;
144
+ /**
145
+ * moda search "<query>" — keyword/semantic/hybrid search across conversation
146
+ * messages. `q` arrives as a positional arg; the runCommand handler rejects an
147
+ * empty/whitespace query before parsing, and min(1) guards the schema path too.
148
+ */
149
+ export declare const SearchSchema: z.ZodObject<{
150
+ q: z.ZodString;
151
+ mode: z.ZodOptional<z.ZodDefault<z.ZodEnum<["keyword", "semantic", "hybrid"]>>>;
152
+ user_id: z.ZodOptional<z.ZodString>;
153
+ time_range: z.ZodOptional<z.ZodDefault<z.ZodEnum<["all", "1h", "3d", "7d", "24h", "30d", "90d"]>>>;
154
+ limit: z.ZodOptional<z.ZodDefault<z.ZodNumber>>;
155
+ }, "strip", z.ZodTypeAny, {
156
+ q: string;
157
+ mode?: "keyword" | "semantic" | "hybrid" | undefined;
158
+ time_range?: "all" | "1h" | "3d" | "7d" | "24h" | "30d" | "90d" | undefined;
159
+ limit?: number | undefined;
160
+ user_id?: string | undefined;
161
+ }, {
162
+ q: string;
163
+ mode?: "keyword" | "semantic" | "hybrid" | undefined;
164
+ time_range?: "all" | "1h" | "3d" | "7d" | "24h" | "30d" | "90d" | undefined;
165
+ limit?: number | undefined;
166
+ user_id?: string | undefined;
167
+ }>;
144
168
  /** moda workflows — recurring workflow catalog (read from agent_job_classes). */
145
169
  export declare const WorkflowsSchema: z.ZodObject<{
146
170
  time_range: z.ZodOptional<z.ZodDefault<z.ZodEnum<["all", "1h", "3d", "7d", "24h", "30d", "90d"]>>>;
package/dist/schemas.js CHANGED
@@ -76,6 +76,19 @@ export const ToolFailureDetailSchema = z.object({
76
76
  // `moda workflows` lists the catalog; `moda runs` lists executions.
77
77
  const RUN_OUTCOMES = ['running', 'succeeded', 'partial', 'failed', 'loop', 'abandoned', 'unknown'];
78
78
  const TIME_RANGES = ['all', '1h', '3d', '7d', '24h', '30d', '90d'];
79
+ const SEARCH_MODES = ['keyword', 'semantic', 'hybrid'];
80
+ /**
81
+ * moda search "<query>" — keyword/semantic/hybrid search across conversation
82
+ * messages. `q` arrives as a positional arg; the runCommand handler rejects an
83
+ * empty/whitespace query before parsing, and min(1) guards the schema path too.
84
+ */
85
+ export const SearchSchema = z.object({
86
+ q: z.string().min(1).max(500),
87
+ mode: z.enum(SEARCH_MODES).default('hybrid').optional(),
88
+ user_id: z.string().optional(),
89
+ time_range: z.enum(TIME_RANGES).default('all').optional(),
90
+ limit: z.number().min(1).max(100).default(20).optional(),
91
+ });
79
92
  /** moda workflows — recurring workflow catalog (read from agent_job_classes). */
80
93
  export const WorkflowsSchema = z.object({
81
94
  time_range: z.enum(TIME_RANGES).default('all').optional(),
@@ -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.0",
3
+ "version": "1.9.0",
4
4
  "description": "CLI for Moda - AI agent analytics and observability",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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>`.