@moda-ai/cli 1.9.0 → 1.10.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/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;
package/dist/schemas.d.ts CHANGED
@@ -141,6 +141,26 @@ export declare const ToolFailureDetailSchema: z.ZodObject<{
141
141
  include_window?: unknown;
142
142
  subtype?: string | undefined;
143
143
  }>;
144
+ /**
145
+ * moda audit <conversation_id|trace_id> — raw, non-deduped span/trace read.
146
+ * `kind=auto` (default) lets the backend try trace first, then conversation.
147
+ */
148
+ export declare const AuditSchema: z.ZodObject<{
149
+ id: z.ZodString;
150
+ kind: z.ZodOptional<z.ZodDefault<z.ZodEnum<["auto", "trace", "conversation"]>>>;
151
+ include_raw: z.ZodOptional<z.ZodEffects<z.ZodBoolean, boolean, unknown>>;
152
+ limit: z.ZodOptional<z.ZodDefault<z.ZodNumber>>;
153
+ }, "strip", z.ZodTypeAny, {
154
+ id: string;
155
+ kind?: "auto" | "trace" | "conversation" | undefined;
156
+ limit?: number | undefined;
157
+ include_raw?: boolean | undefined;
158
+ }, {
159
+ id: string;
160
+ kind?: "auto" | "trace" | "conversation" | undefined;
161
+ limit?: number | undefined;
162
+ include_raw?: unknown;
163
+ }>;
144
164
  /**
145
165
  * moda search "<query>" — keyword/semantic/hybrid search across conversation
146
166
  * messages. `q` arrives as a positional arg; the runCommand handler rejects an
@@ -190,14 +210,14 @@ export declare const RunsSchema: z.ZodObject<{
190
210
  limit?: number | undefined;
191
211
  offset?: number | undefined;
192
212
  user_id?: string | undefined;
193
- outcome?: "unknown" | "running" | "failed" | "succeeded" | "partial" | "loop" | "abandoned" | undefined;
213
+ outcome?: "unknown" | "loop" | "running" | "failed" | "succeeded" | "partial" | "abandoned" | undefined;
194
214
  class_id?: string | undefined;
195
215
  }, {
196
216
  time_range?: "all" | "1h" | "3d" | "7d" | "24h" | "30d" | "90d" | undefined;
197
217
  limit?: number | undefined;
198
218
  offset?: number | undefined;
199
219
  user_id?: string | undefined;
200
- outcome?: "unknown" | "running" | "failed" | "succeeded" | "partial" | "loop" | "abandoned" | undefined;
220
+ outcome?: "unknown" | "loop" | "running" | "failed" | "succeeded" | "partial" | "abandoned" | undefined;
201
221
  class_id?: string | undefined;
202
222
  }>;
203
223
  /** moda run <runId> — single execution detail with side-effect receipts. */
package/dist/schemas.js CHANGED
@@ -68,6 +68,16 @@ export const ToolFailureDetailSchema = z.object({
68
68
  include_window: boolFlag(),
69
69
  window: z.number().min(1).max(5).default(1).optional(),
70
70
  });
71
+ /**
72
+ * moda audit <conversation_id|trace_id> — raw, non-deduped span/trace read.
73
+ * `kind=auto` (default) lets the backend try trace first, then conversation.
74
+ */
75
+ export const AuditSchema = z.object({
76
+ id: z.string().min(1),
77
+ kind: z.enum(['auto', 'trace', 'conversation']).default('auto').optional(),
78
+ include_raw: boolFlag(),
79
+ limit: z.number().min(1).max(1000).default(200).optional(),
80
+ });
71
81
  // ─── Workflows + Runs (agent runs runtime) ──────────────────
72
82
  //
73
83
  // Naming model:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moda-ai/cli",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
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"
@@ -78,6 +78,87 @@ npx -p @moda-ai/cli moda <command>
78
78
  export MODA_API_KEY="moda_sk_..." # from moda.dev/settings
79
79
  ```
80
80
 
81
+ ## Agent protocol
82
+
83
+ `moda` is agent-safe: it picks an output mode automatically, signals how much
84
+ to trust an answer through exit codes, and (in agent mode) emits a
85
+ schema-versioned `moda.agent.v1` JSON envelope you can parse instead of
86
+ scraping prose.
87
+
88
+ ### Output mode auto-selection
89
+
90
+ The mode is resolved in this priority order — first match wins, so you usually
91
+ need no flags at all:
92
+
93
+ 1. **Explicit flags** override everything:
94
+ - `--agent` — `moda.agent.v1` schema-versioned JSON envelope
95
+ - `--json` — raw JSON payload (no envelope)
96
+ - `--agent --stream` — NDJSON stream of `moda.agent_event.v1` events
97
+ - `--human` — force human prose
98
+ 2. **`MODA_FORMAT` env var** — one of `human | json | agent | agent-stream`.
99
+ 3. **Agent-environment auto-detect** — if `CLAUDECODE`, `CURSOR`, `CI`, or
100
+ `MODA_AGENT` is set, default to the agent JSON envelope.
101
+ 4. **TTY auto-detect** — if stdout is not a TTY (piped or redirected), default
102
+ to the agent JSON envelope; an interactive terminal defaults to human prose.
103
+
104
+ Running `moda` from inside Claude Code / Cursor, in CI, or through a pipe
105
+ already yields agent JSON — no flag required.
106
+
107
+ ### Exit codes
108
+
109
+ Branch on the exit code; it is stable and cheaper than parsing the payload:
110
+
111
+ | Exit | Meaning | How to treat it |
112
+ |---|---|---|
113
+ | `0` | ok — answer is grounded in Moda Cloud intelligence | Trust it. |
114
+ | `3` | degraded — Moda Cloud was unavailable; answer synthesized from local Data API evidence | Answer is present but lower-trust; verify before acting. |
115
+ | `1` | error — bad input, unknown command, or unexpected failure | Read `errors[]` in the envelope. |
116
+
117
+ ### The `moda.agent.v1` envelope
118
+
119
+ Fields an agent should read:
120
+
121
+ - `status` — `ok` | `degraded` | `error`.
122
+ - `summary.text` + `summary.confidence` — the answer and how much to trust it
123
+ (`high | medium | low | unknown`).
124
+ - `evidence_refs[]` — each `{ id, kind, label, path }`. When `path` looks like
125
+ `conversation/<id>`, open it with `moda context <id>`.
126
+ - `next_commands[]` — suggested follow-up commands (each carries `mutability`
127
+ and `requires_approval`).
128
+ - `warnings[]`, `errors[]`.
129
+ - `data` — the raw command payload. For `ask`, `data.source` is `cloud` or
130
+ `local_fallback` and `data.degraded` is a boolean — the canonical in-payload
131
+ way to detect degradation (in addition to exit code 3).
132
+
133
+ ### Detecting a degraded answer
134
+
135
+ Treat an answer as degraded if **any** of these hold:
136
+
137
+ - exit code is `3`, **or**
138
+ - `data.degraded === true` (for `ask`; equivalently `data.source === "local_fallback"`), **or**
139
+ - `status === "degraded"`.
140
+
141
+ ### Schema introspection
142
+
143
+ ```bash
144
+ moda --json-schemas # print the JSON Schemas for the agent output contract
145
+ ```
146
+
147
+ ### Recommended agent usage
148
+
149
+ ```bash
150
+ answer=$(moda ask "what should I fix first?")
151
+ case $? in
152
+ 0) printf 'grounded answer:\n%s\n' "$answer" ;;
153
+ 3) printf 'DEGRADED (local fallback — verify before acting):\n%s\n' "$answer" ;;
154
+ *) echo "moda ask failed:"; echo "$answer" | jq -r '.errors[].message'; exit 1 ;;
155
+ esac
156
+
157
+ # Pull the trusted parts straight out of the envelope
158
+ echo "$answer" | jq -r '.summary.text'
159
+ echo "$answer" | jq -r '.evidence_refs[] | "\(.kind)\t\(.label)\t\(.path // "")"'
160
+ ```
161
+
81
162
  ## Procedure
82
163
 
83
164
  Two entry points depending on the question:
@@ -146,6 +227,47 @@ moda overview --days-back=30
146
227
  Returns total conversations, trend, frustration rate, tool failure summary,
147
228
  top clusters, recent activity.
148
229
 
230
+ ### 2b. Ask & investigate (production intelligence)
231
+
232
+ `moda ask` answers a natural-language production/harness question; `moda
233
+ investigate` returns a ranked, evidence-backed list of the issues worth fixing
234
+ first. Both default to the last 7 days (`--days-back=N`, 1–90).
235
+
236
+ ```bash
237
+ moda ask "what should I fix first?" # grounded answer + evidence
238
+ moda ask "why are refunds failing?" --days-back=30
239
+ moda investigate # ranked findings, last 7d
240
+ moda investigate --days-back=14
241
+ moda overview # health briefing (see §2)
242
+ ```
243
+
244
+ In an agent environment these emit the `moda.agent.v1` envelope (see
245
+ [Agent protocol](#agent-protocol)). For `ask`, branch on the exit code (`0`
246
+ grounded, `3` degraded, `1` error) and read `data.source` /
247
+ `data.degraded` to know whether the answer came from Moda Cloud or a local
248
+ Data API fallback. Abbreviated envelope:
249
+
250
+ ```json
251
+ {
252
+ "schema_version": "moda.agent.v1",
253
+ "command": "ask",
254
+ "status": "degraded",
255
+ "summary": { "text": "Strongest signal is: lookupCustomer is failing in production.", "confidence": "medium" },
256
+ "evidence_refs": [
257
+ { "id": "tool:lookupCustomer", "kind": "tool_failure", "label": "lookupCustomer: 12 failure(s)", "path": null }
258
+ ],
259
+ "next_commands": [
260
+ { "command": "moda tool-failure-detail lookupCustomer --include-window", "purpose": "Inspect tool failure examples and conversation anchors.", "mutability": "read", "requires_approval": false }
261
+ ],
262
+ "warnings": ["Cloud ask endpoint unavailable; synthesized answer from local Data API evidence."],
263
+ "errors": [],
264
+ "data": { "source": "local_fallback", "degraded": true, "question": "what should I fix first?", "answer": "…", "confidence": "medium" }
265
+ }
266
+ ```
267
+
268
+ `moda investigate` also accepts scoping flags (`--tool`, `--run`,
269
+ `--conversation`, `--loop`) to focus the ranking on one signal.
270
+
149
271
  ### 3. Browse topic clusters
150
272
 
151
273
  ```bash
@@ -178,6 +300,27 @@ moda context <conversation_id> --msg-index=5 # center on message 5
178
300
  moda context <conversation_id> --window=3 # 3 messages each side (max 5)
179
301
  ```
180
302
 
303
+ `moda context` is turn-windowed (≤5 turns, deduped, no tool spans). For the raw,
304
+ complete picture of what was captured — every span, the parent/child hierarchy,
305
+ tool calls, prompt/response bodies, and `gen_ai.*` attributes — use `moda audit`.
306
+
307
+ ### 4b. Audit raw spans/trace (completeness, orphans, duplicates)
308
+
309
+ ```bash
310
+ moda audit <conversation_id|trace_id> # auto-detects trace vs conversation
311
+ moda audit <trace_id> --kind=trace # force trace lookup
312
+ moda audit <conversation_id> --include-raw # attach verbatim raw_event bodies
313
+ ```
314
+
315
+ Returns the raw, NON-deduped span set from the Layer-0 store (`events_raw`), so
316
+ nothing is collapsed. Use it to verify capture completeness: the `summary` gives
317
+ `total_spans`, `by_type` (llm/tool/agent/…), plus `orphan_count` (spans whose
318
+ parent is missing) and `duplicate_count` (double-instrumentation — the same
319
+ logical call emitted as >1 span). Each span carries `span_id`, `parent_span_id`,
320
+ `trace_id`, `type` (tool spans included), timing, `prompt`, `response`, and the
321
+ full semconv `attributes`. This is the honest substrate for auditing whether
322
+ Moda captured every LLM/tool call for a conversation.
323
+
181
324
  ### 5. Frustration analysis
182
325
 
183
326
  ```bash
@@ -393,6 +536,9 @@ moda tool-failures | jq '.tools[] | {tool: .tool_name, failures: .failure_count}
393
536
  ## Validation — how to know it worked
394
537
 
395
538
  - Every successful command exits 0 and prints valid JSON to stdout.
539
+ - `moda ask` / `moda investigate` may exit `3` (degraded): a real,
540
+ lower-trust answer synthesized from local evidence — not a failure. Treat
541
+ exit `1` as the only hard error (see [Agent protocol](#agent-protocol)).
396
542
  - Errors print to stderr with non-zero exit. Check stderr before claiming
397
543
  success.
398
544
  - If the JSON has `"conversations": []` or `"frustrations": []`, the call
@@ -441,6 +587,8 @@ npx: `npx -p @moda-ai/cli moda <command>`.
441
587
  | `moda init` | Browser auth + provision API key + install agent rules |
442
588
  | `moda search "<query>"` | **Message-grain semantic/keyword/hybrid search (incl. tool calls); primary content-discovery path** |
443
589
  | `moda overview` | Dashboard KPIs, top clusters, recent activity |
590
+ | `moda ask "<question>"` | Natural-language production/harness answer with evidence (exit `3` = degraded local fallback) |
591
+ | `moda investigate` | Rank production issues with evidence + next commands |
444
592
  | `moda clusters` | Browse topic cluster hierarchy |
445
593
  | `moda cluster-conversations <node_id>` | Conversations in a cluster |
446
594
  | `moda conversations` | List/filter conversations by structured fields |
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