@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 +34 -2
- package/dist/cli.js +57 -1
- package/dist/command-context.d.ts +15 -0
- package/dist/command-context.js +7 -0
- package/dist/config.d.ts +2 -1
- package/dist/config.js +7 -10
- package/dist/profiles.d.ts +9 -1
- package/dist/profiles.js +110 -7
- package/dist/telemetry.d.ts +38 -0
- package/dist/telemetry.js +104 -0
- package/package.json +2 -3
- package/skills/moda-cli/SKILL.md +112 -10
- package/.env.example +0 -8
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
|
-
|
|
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;
|
package/dist/command-context.js
CHANGED
|
@@ -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 ->
|
|
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,
|
|
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
|
|
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
|
-
//
|
|
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 ->
|
|
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
|
-
|
|
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
|
package/dist/profiles.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export declare const PROFILE_CONFIG_VERSION = 1;
|
|
2
|
-
export declare const PROD_BASE_URL = "https://
|
|
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://
|
|
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:
|
|
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/telemetry.d.ts
CHANGED
|
@@ -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.
|
|
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"
|
package/skills/moda-cli/SKILL.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: moda-cli
|
|
3
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
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
|
-
###
|
|
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
|
-
|
|
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` |
|
|
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