@moda-ai/cli 1.13.10 → 1.14.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/README.md +0 -4
- package/dist/agent-schemas.js +1 -2
- package/dist/ask-stream.d.ts +90 -0
- package/dist/ask-stream.js +330 -0
- package/dist/cli.js +7 -40
- package/dist/production-intelligence.d.ts +18 -4
- package/dist/production-intelligence.js +99 -79
- package/dist/schemas.d.ts +0 -16
- package/dist/schemas.js +0 -8
- package/dist/tui.js +0 -1
- package/package.json +1 -1
- package/skills/integration/index.json +2 -2
- package/skills/moda-cli/SKILL.md +3 -19
package/README.md
CHANGED
|
@@ -58,9 +58,6 @@ moda tool-failures # Tool failure overview
|
|
|
58
58
|
moda tool-failure-detail <tool_name> # Per-tool failure detail
|
|
59
59
|
moda tool-failure-detail <tool_name> --include-window --window=1
|
|
60
60
|
|
|
61
|
-
moda loops # Currently-active loop detections
|
|
62
|
-
moda loop <detection_id> # One loop detail (signature + blast radius)
|
|
63
|
-
|
|
64
61
|
# Prompt management
|
|
65
62
|
moda prompts init # Create .moda/prompts.yml
|
|
66
63
|
moda prompts status # Read-only local prompt status
|
|
@@ -75,7 +72,6 @@ All commands output JSON. Pipe to `jq` for filtering:
|
|
|
75
72
|
```bash
|
|
76
73
|
moda overview | jq '.frustrations'
|
|
77
74
|
moda frustrations | jq '.frustrations[].primary_cause'
|
|
78
|
-
moda loops | jq '.loops | sort_by(-.cycle_visits) | .[0:5] | .[].loop_signature_human'
|
|
79
75
|
```
|
|
80
76
|
|
|
81
77
|
## Prompt Management
|
package/dist/agent-schemas.js
CHANGED
|
@@ -224,7 +224,7 @@ const productionEvidenceRefSchema = {
|
|
|
224
224
|
id: { type: 'string' },
|
|
225
225
|
kind: {
|
|
226
226
|
type: 'string',
|
|
227
|
-
enum: ['data_api', 'tool_failure', 'frustration', '
|
|
227
|
+
enum: ['data_api', 'tool_failure', 'frustration', 'run', 'harness', 'ask'],
|
|
228
228
|
},
|
|
229
229
|
label: { type: 'string' },
|
|
230
230
|
endpoint: { type: 'string' },
|
|
@@ -284,7 +284,6 @@ const productionFindingSchema = {
|
|
|
284
284
|
type: 'string',
|
|
285
285
|
enum: [
|
|
286
286
|
'tool_failure',
|
|
287
|
-
'loop',
|
|
288
287
|
'frustration',
|
|
289
288
|
'partial_run',
|
|
290
289
|
'prompt_regression',
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { AgentConfidence } from './output.js';
|
|
2
|
+
/** Mirrors backend `AgentPhase`. */
|
|
3
|
+
export type AskStreamPhase = 'idle' | 'planning' | 'searching' | 'analyzing' | 'writing' | 'done' | 'error' | 'stopped';
|
|
4
|
+
/** Mirrors backend `StreamCitation`. */
|
|
5
|
+
export interface AskStreamCitation {
|
|
6
|
+
id: string;
|
|
7
|
+
n: number;
|
|
8
|
+
label: string;
|
|
9
|
+
source: string;
|
|
10
|
+
snippet?: string;
|
|
11
|
+
href?: string;
|
|
12
|
+
}
|
|
13
|
+
/** Everything the CLI needs from a completed stream to render the final answer. */
|
|
14
|
+
export interface AskStreamOutcome {
|
|
15
|
+
answer: string;
|
|
16
|
+
confidence: AgentConfidence;
|
|
17
|
+
citations: AskStreamCitation[];
|
|
18
|
+
nextCommands: string[];
|
|
19
|
+
elapsedMs?: number;
|
|
20
|
+
/**
|
|
21
|
+
* Set when the stream terminated with a terminal `error` event (the agent
|
|
22
|
+
* failed server-side). Transport failures throw `AskStreamError` instead.
|
|
23
|
+
*/
|
|
24
|
+
errorMessage?: string;
|
|
25
|
+
}
|
|
26
|
+
/** Signals "fall back to the buffered ask endpoint" — never surfaced raw. */
|
|
27
|
+
export declare class AskStreamError extends Error {
|
|
28
|
+
constructor(message: string);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Incremental SSE frame parser. Feed decoded text chunks (arbitrarily split —
|
|
32
|
+
* even mid `data:` line or mid multi-byte frame); complete `data:` payloads
|
|
33
|
+
* are returned in order. Handles LF and CRLF framing, multi-line `data:`
|
|
34
|
+
* fields, and ignores comments (`: ...`) and non-data fields (`event:`, `id:`).
|
|
35
|
+
*/
|
|
36
|
+
export declare class SseParser {
|
|
37
|
+
private buffer;
|
|
38
|
+
private pendingCr;
|
|
39
|
+
push(chunk: string): string[];
|
|
40
|
+
/** Drain a final frame that was not terminated by a blank line. */
|
|
41
|
+
flush(): string[];
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Consumes parsed stream events: renders live progress lines through
|
|
45
|
+
* `onProgress` and accumulates the final outcome. `finish()` throws
|
|
46
|
+
* `AskStreamError` unless a terminal event (`done` or `error`) arrived, so a
|
|
47
|
+
* connection dropped mid-run triggers the buffered fallback instead of
|
|
48
|
+
* rendering a truncated answer.
|
|
49
|
+
*/
|
|
50
|
+
export declare class AskStreamCollector {
|
|
51
|
+
private readonly onProgress;
|
|
52
|
+
private answer;
|
|
53
|
+
private confidence;
|
|
54
|
+
private citations;
|
|
55
|
+
private nextCommands;
|
|
56
|
+
private elapsedMs;
|
|
57
|
+
private errorMessage;
|
|
58
|
+
private terminal;
|
|
59
|
+
private lastPhase;
|
|
60
|
+
private toolNames;
|
|
61
|
+
constructor(onProgress?: (line: string) => void);
|
|
62
|
+
handle(raw: unknown): void;
|
|
63
|
+
isTerminal(): boolean;
|
|
64
|
+
finish(): AskStreamOutcome;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Iterate any chunk source (a fetch body, or test fixtures) through the SSE
|
|
68
|
+
* parser into the collector. Exported separately from `streamAsk` so tests can
|
|
69
|
+
* exercise chunk-splitting behavior without a network layer.
|
|
70
|
+
*/
|
|
71
|
+
export declare function consumeAskSse(chunks: AsyncIterable<Uint8Array | string>, collector: AskStreamCollector): Promise<AskStreamOutcome>;
|
|
72
|
+
export interface StreamAskOptions {
|
|
73
|
+
question: string;
|
|
74
|
+
daysBack: number;
|
|
75
|
+
profile?: string;
|
|
76
|
+
harness?: Record<string, unknown> | null;
|
|
77
|
+
/** Overall wall-clock budget for the run (default 250s, like buffered ask). */
|
|
78
|
+
timeoutMs?: number;
|
|
79
|
+
onProgress?: (line: string) => void;
|
|
80
|
+
/** Test seam. */
|
|
81
|
+
fetchImpl?: typeof fetch;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* POST the question to `/api/cli/ask/stream` and consume the SSE response.
|
|
85
|
+
* Throws `AskStreamError` on any transport/framing problem (non-2xx, wrong
|
|
86
|
+
* content type, network drop, truncated stream) so callers can fall back to
|
|
87
|
+
* the buffered endpoint.
|
|
88
|
+
*/
|
|
89
|
+
export declare function streamAsk(options: StreamAskOptions): Promise<AskStreamOutcome>;
|
|
90
|
+
//# sourceMappingURL=ask-stream.d.ts.map
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
// src/ask-stream.ts
|
|
2
|
+
//
|
|
3
|
+
// SSE consumer for `POST /api/cli/ask/stream` (the backend's streaming ask
|
|
4
|
+
// endpoint). Mirrors the `AgentStreamEvent` wire protocol defined in
|
|
5
|
+
// apps/moda-backend/src/data-agent/agent/stream-events.ts: SSE frames of
|
|
6
|
+
// `data: <json>` carrying phase / thinking / tool_start / tool_end / sources /
|
|
7
|
+
// text / done / error events.
|
|
8
|
+
//
|
|
9
|
+
// Design contract: ANY transport, framing, or protocol problem throws
|
|
10
|
+
// `AskStreamError` so the caller can transparently fall back to the buffered
|
|
11
|
+
// `POST /api/cli/ask` path — a user must never get a worse outcome than the
|
|
12
|
+
// buffered request. A terminal `error` event from the agent is NOT a transport
|
|
13
|
+
// failure: it is returned as an outcome (`errorMessage`) and rendered the same
|
|
14
|
+
// way buffered agent errors render.
|
|
15
|
+
import { resolveApiKey, resolveModaBaseUrl } from './config.js';
|
|
16
|
+
/** Signals "fall back to the buffered ask endpoint" — never surfaced raw. */
|
|
17
|
+
export class AskStreamError extends Error {
|
|
18
|
+
constructor(message) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = 'AskStreamError';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Incremental SSE frame parser. Feed decoded text chunks (arbitrarily split —
|
|
25
|
+
* even mid `data:` line or mid multi-byte frame); complete `data:` payloads
|
|
26
|
+
* are returned in order. Handles LF and CRLF framing, multi-line `data:`
|
|
27
|
+
* fields, and ignores comments (`: ...`) and non-data fields (`event:`, `id:`).
|
|
28
|
+
*/
|
|
29
|
+
export class SseParser {
|
|
30
|
+
buffer = '';
|
|
31
|
+
pendingCr = false;
|
|
32
|
+
push(chunk) {
|
|
33
|
+
let text = (this.pendingCr ? '\r' : '') + chunk;
|
|
34
|
+
this.pendingCr = false;
|
|
35
|
+
// Hold back a trailing '\r' — it may be half of a CRLF split across chunks.
|
|
36
|
+
if (text.endsWith('\r')) {
|
|
37
|
+
this.pendingCr = true;
|
|
38
|
+
text = text.slice(0, -1);
|
|
39
|
+
}
|
|
40
|
+
this.buffer += text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
41
|
+
const payloads = [];
|
|
42
|
+
let boundary = this.buffer.indexOf('\n\n');
|
|
43
|
+
while (boundary !== -1) {
|
|
44
|
+
const frame = this.buffer.slice(0, boundary);
|
|
45
|
+
this.buffer = this.buffer.slice(boundary + 2);
|
|
46
|
+
const payload = dataPayloadOf(frame);
|
|
47
|
+
if (payload !== null)
|
|
48
|
+
payloads.push(payload);
|
|
49
|
+
boundary = this.buffer.indexOf('\n\n');
|
|
50
|
+
}
|
|
51
|
+
return payloads;
|
|
52
|
+
}
|
|
53
|
+
/** Drain a final frame that was not terminated by a blank line. */
|
|
54
|
+
flush() {
|
|
55
|
+
const remaining = this.buffer + (this.pendingCr ? '\r' : '');
|
|
56
|
+
this.buffer = '';
|
|
57
|
+
this.pendingCr = false;
|
|
58
|
+
const payload = dataPayloadOf(remaining.replace(/\r\n/g, '\n').replace(/\r/g, '\n'));
|
|
59
|
+
return payload !== null ? [payload] : [];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function dataPayloadOf(frame) {
|
|
63
|
+
const dataLines = [];
|
|
64
|
+
for (const line of frame.split('\n')) {
|
|
65
|
+
if (line.startsWith('data:')) {
|
|
66
|
+
dataLines.push(line.slice(5).replace(/^ /, ''));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (dataLines.length === 0)
|
|
70
|
+
return null;
|
|
71
|
+
const payload = dataLines.join('\n');
|
|
72
|
+
return payload.trim().length > 0 ? payload : null;
|
|
73
|
+
}
|
|
74
|
+
/** Phases worth a human status line (terminal/idle phases are omitted). */
|
|
75
|
+
const RENDERED_PHASES = new Set(['planning', 'searching', 'analyzing', 'writing']);
|
|
76
|
+
/**
|
|
77
|
+
* Consumes parsed stream events: renders live progress lines through
|
|
78
|
+
* `onProgress` and accumulates the final outcome. `finish()` throws
|
|
79
|
+
* `AskStreamError` unless a terminal event (`done` or `error`) arrived, so a
|
|
80
|
+
* connection dropped mid-run triggers the buffered fallback instead of
|
|
81
|
+
* rendering a truncated answer.
|
|
82
|
+
*/
|
|
83
|
+
export class AskStreamCollector {
|
|
84
|
+
onProgress;
|
|
85
|
+
answer = '';
|
|
86
|
+
confidence = 'medium';
|
|
87
|
+
citations = [];
|
|
88
|
+
nextCommands = [];
|
|
89
|
+
elapsedMs;
|
|
90
|
+
errorMessage;
|
|
91
|
+
terminal = false;
|
|
92
|
+
lastPhase;
|
|
93
|
+
toolNames = new Map();
|
|
94
|
+
constructor(onProgress = () => { }) {
|
|
95
|
+
this.onProgress = onProgress;
|
|
96
|
+
}
|
|
97
|
+
handle(raw) {
|
|
98
|
+
if (this.terminal)
|
|
99
|
+
return;
|
|
100
|
+
const event = raw;
|
|
101
|
+
if (!event || typeof event !== 'object' || typeof event.type !== 'string') {
|
|
102
|
+
throw new AskStreamError('stream emitted a frame without an event type');
|
|
103
|
+
}
|
|
104
|
+
switch (event.type) {
|
|
105
|
+
case 'phase': {
|
|
106
|
+
const phase = typeof event.phase === 'string' ? event.phase : '';
|
|
107
|
+
if (RENDERED_PHASES.has(phase) && phase !== this.lastPhase) {
|
|
108
|
+
this.lastPhase = phase;
|
|
109
|
+
this.onProgress(`▸ ${phase}…`);
|
|
110
|
+
}
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
case 'thinking':
|
|
114
|
+
// Model narration is too chatty for one-line terminal progress; skip.
|
|
115
|
+
break;
|
|
116
|
+
case 'tool_start': {
|
|
117
|
+
const tool = event.tool;
|
|
118
|
+
const id = typeof tool?.id === 'string' ? tool.id : '';
|
|
119
|
+
const name = typeof tool?.tool === 'string' ? tool.tool : 'tool';
|
|
120
|
+
const title = typeof tool?.title === 'string' ? tool.title : name;
|
|
121
|
+
if (id)
|
|
122
|
+
this.toolNames.set(id, name);
|
|
123
|
+
this.onProgress(`• ${name} — ${title}…`);
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
case 'tool_end': {
|
|
127
|
+
const id = typeof event.id === 'string' ? event.id : '';
|
|
128
|
+
const name = this.toolNames.get(id) ?? 'tool';
|
|
129
|
+
this.onProgress(`• ${name} — ${toolEndDetail(event)}`);
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
case 'sources': {
|
|
133
|
+
this.citations = Array.isArray(event.citations)
|
|
134
|
+
? event.citations.flatMap(normalizeCitation)
|
|
135
|
+
: [];
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
case 'text': {
|
|
139
|
+
if (typeof event.delta === 'string')
|
|
140
|
+
this.answer += event.delta;
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
case 'done': {
|
|
144
|
+
this.terminal = true;
|
|
145
|
+
this.confidence = normalizeStreamConfidence(event.confidence);
|
|
146
|
+
if (typeof event.elapsedMs === 'number')
|
|
147
|
+
this.elapsedMs = event.elapsedMs;
|
|
148
|
+
this.nextCommands = Array.isArray(event.nextCommands)
|
|
149
|
+
? event.nextCommands.filter((item) => typeof item === 'string')
|
|
150
|
+
: [];
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
case 'error': {
|
|
154
|
+
this.terminal = true;
|
|
155
|
+
this.errorMessage = typeof event.message === 'string' && event.message.trim().length > 0
|
|
156
|
+
? event.message
|
|
157
|
+
: 'The analysis agent hit an error and could not complete the request.';
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
default:
|
|
161
|
+
// Forward-compatible: unknown event types are ignored, not fatal.
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
isTerminal() {
|
|
166
|
+
return this.terminal;
|
|
167
|
+
}
|
|
168
|
+
finish() {
|
|
169
|
+
if (!this.terminal) {
|
|
170
|
+
throw new AskStreamError('stream ended before a terminal done/error event');
|
|
171
|
+
}
|
|
172
|
+
if (!this.errorMessage && this.answer.trim().length === 0) {
|
|
173
|
+
// Mirror the buffered contract: an empty answer means "no usable cloud
|
|
174
|
+
// answer" and must trigger the fallback path, not render as blank prose.
|
|
175
|
+
throw new AskStreamError('stream completed with an empty answer');
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
answer: this.answer,
|
|
179
|
+
confidence: this.confidence,
|
|
180
|
+
citations: this.citations,
|
|
181
|
+
nextCommands: this.nextCommands,
|
|
182
|
+
...(this.elapsedMs !== undefined ? { elapsedMs: this.elapsedMs } : {}),
|
|
183
|
+
...(this.errorMessage ? { errorMessage: this.errorMessage } : {}),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Tool cards must stay one line: backend summaries can be multi-line prose
|
|
189
|
+
* (e.g. get_timeseries emits a day-by-day breakdown), which would spill the
|
|
190
|
+
* whole table into the progress area. Collapse whitespace and truncate.
|
|
191
|
+
*/
|
|
192
|
+
const TOOL_CARD_DETAIL_MAX = 100;
|
|
193
|
+
function oneLine(text) {
|
|
194
|
+
const collapsed = text.replace(/\s+/g, ' ').trim();
|
|
195
|
+
return collapsed.length > TOOL_CARD_DETAIL_MAX
|
|
196
|
+
? `${collapsed.slice(0, TOOL_CARD_DETAIL_MAX - 1)}…`
|
|
197
|
+
: collapsed;
|
|
198
|
+
}
|
|
199
|
+
function toolEndDetail(event) {
|
|
200
|
+
const summary = typeof event.summary === 'string' ? event.summary : '';
|
|
201
|
+
const secs = typeof event.ms === 'number' ? `${(event.ms / 1000).toFixed(1)}s` : undefined;
|
|
202
|
+
if (event.status === 'error') {
|
|
203
|
+
const reason = typeof event.error === 'string' && event.error ? event.error : summary || 'failed';
|
|
204
|
+
return joinDetail(`failed: ${oneLine(reason)}`, secs);
|
|
205
|
+
}
|
|
206
|
+
const detail = summary ||
|
|
207
|
+
(typeof event.hits === 'number'
|
|
208
|
+
? `${event.hits} hits`
|
|
209
|
+
: typeof event.rows === 'number'
|
|
210
|
+
? `${event.rows} rows`
|
|
211
|
+
: 'done');
|
|
212
|
+
return joinDetail(oneLine(detail), secs);
|
|
213
|
+
}
|
|
214
|
+
function joinDetail(detail, secs) {
|
|
215
|
+
return secs ? `${detail}, ${secs}` : detail;
|
|
216
|
+
}
|
|
217
|
+
function normalizeCitation(item) {
|
|
218
|
+
const record = item;
|
|
219
|
+
if (!record || typeof record !== 'object')
|
|
220
|
+
return [];
|
|
221
|
+
const label = typeof record.label === 'string' ? record.label : undefined;
|
|
222
|
+
const id = typeof record.id === 'string' ? record.id : undefined;
|
|
223
|
+
if (!label && !id)
|
|
224
|
+
return [];
|
|
225
|
+
return [{
|
|
226
|
+
id: id ?? `ref-${typeof record.n === 'number' ? record.n : 0}`,
|
|
227
|
+
n: typeof record.n === 'number' ? record.n : 0,
|
|
228
|
+
label: label ?? id ?? 'Cloud evidence',
|
|
229
|
+
source: typeof record.source === 'string' ? record.source : 'evidence',
|
|
230
|
+
...(typeof record.snippet === 'string' ? { snippet: record.snippet } : {}),
|
|
231
|
+
...(typeof record.href === 'string' ? { href: record.href } : {}),
|
|
232
|
+
}];
|
|
233
|
+
}
|
|
234
|
+
function normalizeStreamConfidence(value) {
|
|
235
|
+
return value === 'high' || value === 'medium' || value === 'low' || value === 'unknown'
|
|
236
|
+
? value
|
|
237
|
+
: 'medium';
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Iterate any chunk source (a fetch body, or test fixtures) through the SSE
|
|
241
|
+
* parser into the collector. Exported separately from `streamAsk` so tests can
|
|
242
|
+
* exercise chunk-splitting behavior without a network layer.
|
|
243
|
+
*/
|
|
244
|
+
export async function consumeAskSse(chunks, collector) {
|
|
245
|
+
const parser = new SseParser();
|
|
246
|
+
const decoder = new TextDecoder();
|
|
247
|
+
for await (const chunk of chunks) {
|
|
248
|
+
const text = typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true });
|
|
249
|
+
for (const payload of parser.push(text)) {
|
|
250
|
+
collector.handle(parsePayload(payload));
|
|
251
|
+
if (collector.isTerminal())
|
|
252
|
+
return collector.finish();
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
for (const payload of parser.flush()) {
|
|
256
|
+
collector.handle(parsePayload(payload));
|
|
257
|
+
if (collector.isTerminal())
|
|
258
|
+
return collector.finish();
|
|
259
|
+
}
|
|
260
|
+
return collector.finish();
|
|
261
|
+
}
|
|
262
|
+
function parsePayload(payload) {
|
|
263
|
+
try {
|
|
264
|
+
return JSON.parse(payload);
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
throw new AskStreamError('stream emitted an unparseable frame');
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const DEFAULT_STREAM_TIMEOUT_MS = 250_000;
|
|
271
|
+
/**
|
|
272
|
+
* POST the question to `/api/cli/ask/stream` and consume the SSE response.
|
|
273
|
+
* Throws `AskStreamError` on any transport/framing problem (non-2xx, wrong
|
|
274
|
+
* content type, network drop, truncated stream) so callers can fall back to
|
|
275
|
+
* the buffered endpoint.
|
|
276
|
+
*/
|
|
277
|
+
export async function streamAsk(options) {
|
|
278
|
+
const profileOptions = options.profile ? { profile: options.profile } : {};
|
|
279
|
+
const url = `${resolveModaBaseUrl(profileOptions)}/api/cli/ask/stream`;
|
|
280
|
+
const controller = new AbortController();
|
|
281
|
+
const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? DEFAULT_STREAM_TIMEOUT_MS);
|
|
282
|
+
try {
|
|
283
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
284
|
+
let response;
|
|
285
|
+
try {
|
|
286
|
+
response = await fetchImpl(url, {
|
|
287
|
+
method: 'POST',
|
|
288
|
+
headers: {
|
|
289
|
+
'x-api-key': resolveApiKey(profileOptions) ?? '',
|
|
290
|
+
'Content-Type': 'application/json',
|
|
291
|
+
Accept: 'text/event-stream',
|
|
292
|
+
},
|
|
293
|
+
body: JSON.stringify({
|
|
294
|
+
question: options.question,
|
|
295
|
+
daysBack: options.daysBack,
|
|
296
|
+
profile: options.profile,
|
|
297
|
+
harness: options.harness,
|
|
298
|
+
}),
|
|
299
|
+
signal: controller.signal,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
throw new AskStreamError(`stream request failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
304
|
+
}
|
|
305
|
+
if (!response.ok) {
|
|
306
|
+
throw new AskStreamError(`stream endpoint returned HTTP ${response.status}`);
|
|
307
|
+
}
|
|
308
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
309
|
+
if (!contentType.includes('text/event-stream')) {
|
|
310
|
+
// An old frontend deploy without the stream proxy can 200 with JSON/HTML.
|
|
311
|
+
throw new AskStreamError(`stream endpoint returned unexpected content type '${contentType || 'unknown'}'`);
|
|
312
|
+
}
|
|
313
|
+
if (!response.body) {
|
|
314
|
+
throw new AskStreamError('stream response had no body');
|
|
315
|
+
}
|
|
316
|
+
const collector = new AskStreamCollector(options.onProgress);
|
|
317
|
+
try {
|
|
318
|
+
return await consumeAskSse(response.body, collector);
|
|
319
|
+
}
|
|
320
|
+
catch (error) {
|
|
321
|
+
if (error instanceof AskStreamError)
|
|
322
|
+
throw error;
|
|
323
|
+
throw new AskStreamError(`stream read failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
finally {
|
|
327
|
+
clearTimeout(timeout);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
//# sourceMappingURL=ask-stream.js.map
|
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, SearchSchema, WorldStateSchema, ContextSchema, AuditSchema, FrustrationsSchema, ToolFailuresSchema, ToolFailureDetailSchema,
|
|
5
|
+
import { OverviewSchema, ClustersSchema, ClusterConversationsSchema, ConversationsSchema, SearchSchema, WorldStateSchema, ContextSchema, AuditSchema, FrustrationsSchema, ToolFailuresSchema, ToolFailureDetailSchema, } 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
|
|
@@ -171,9 +171,6 @@ Commands:
|
|
|
171
171
|
tool-failures Get tool failure overview
|
|
172
172
|
tool-failure-detail <tool_name> Get per-tool failure detail
|
|
173
173
|
|
|
174
|
-
loops Currently-active loop detections
|
|
175
|
-
loop <detection_id> Single loop detail
|
|
176
|
-
|
|
177
174
|
Prompt management:
|
|
178
175
|
prompts init Create .moda/prompts.yml
|
|
179
176
|
prompts status Show local prompt changes
|
|
@@ -206,6 +203,7 @@ Common flags:
|
|
|
206
203
|
--agent Emit schema-versioned agent JSON
|
|
207
204
|
--stream With --agent, emit NDJSON agent events
|
|
208
205
|
--human Force human prose (overrides auto-detection)
|
|
206
|
+
--no-stream Disable live ask progress (buffered request)
|
|
209
207
|
|
|
210
208
|
Output auto-detection (no flags needed):
|
|
211
209
|
Piped/redirected stdout, CI, or an agent env (CLAUDECODE/CURSOR/MODA_AGENT)
|
|
@@ -306,8 +304,6 @@ Examples:
|
|
|
306
304
|
moda context <conversation_id> --window=3
|
|
307
305
|
moda audit <conversation_id|trace_id>
|
|
308
306
|
moda audit <trace_id> --kind=trace --json
|
|
309
|
-
moda loops --limit=10
|
|
310
|
-
moda loop 12345678-1234-1234-1234-1234567890ab
|
|
311
307
|
moda prompts status
|
|
312
308
|
moda prompts sync
|
|
313
309
|
moda prompts promote support.triage --label=prod --version=pver_abc123
|
|
@@ -617,27 +613,6 @@ export async function runCommand(command, positional, flags, positionals = posit
|
|
|
617
613
|
await printWithOptionalWindows(withToolFailureAnchors(data), 'examples', params.include_window === true, params.window ?? 1, context.output);
|
|
618
614
|
break;
|
|
619
615
|
}
|
|
620
|
-
case 'loops': {
|
|
621
|
-
const args = flagsToArgs(flags);
|
|
622
|
-
const params = LoopsSchema.parse(args);
|
|
623
|
-
const query = new URLSearchParams();
|
|
624
|
-
if (params.limit)
|
|
625
|
-
query.set('limit', params.limit.toString());
|
|
626
|
-
const queryString = query.toString() ? `?${query.toString()}` : '';
|
|
627
|
-
const data = await callDataAPI(`/loops/active${queryString}`);
|
|
628
|
-
context.output.writeData(data);
|
|
629
|
-
break;
|
|
630
|
-
}
|
|
631
|
-
case 'loop': {
|
|
632
|
-
if (!positional) {
|
|
633
|
-
throw new CliInputError('<detection_id> is required', 'Usage: moda loop <detection_id>');
|
|
634
|
-
}
|
|
635
|
-
const args = flagsToArgs(flags, 'detection_id', positional);
|
|
636
|
-
const params = LoopDetailSchema.parse(args);
|
|
637
|
-
const data = await callDataAPI(`/loops/${params.detection_id}`);
|
|
638
|
-
context.output.writeData(data);
|
|
639
|
-
break;
|
|
640
|
-
}
|
|
641
616
|
default:
|
|
642
617
|
throw new CliInputError(`Unknown command '${command}'`, 'Run "moda --help" for usage information.');
|
|
643
618
|
}
|
|
@@ -966,7 +941,11 @@ export const commandRegistry = createCommandRegistry([
|
|
|
966
941
|
legacyCommand({
|
|
967
942
|
name: 'ask',
|
|
968
943
|
description: 'Ask Moda a production intelligence question',
|
|
969
|
-
examples: [
|
|
944
|
+
examples: [
|
|
945
|
+
'moda ask "what should I fix first?"',
|
|
946
|
+
'moda ask "what should I fix first?" --no-stream',
|
|
947
|
+
'moda --agent ask "why did this run fail?"',
|
|
948
|
+
],
|
|
970
949
|
...dataApiDefaults,
|
|
971
950
|
}),
|
|
972
951
|
legacyCommand({
|
|
@@ -1044,18 +1023,6 @@ export const commandRegistry = createCommandRegistry([
|
|
|
1044
1023
|
examples: ['moda tool-failure-detail search --include-window'],
|
|
1045
1024
|
...dataApiDefaults,
|
|
1046
1025
|
}),
|
|
1047
|
-
legacyCommand({
|
|
1048
|
-
name: 'loops',
|
|
1049
|
-
description: 'List currently-active loop detections',
|
|
1050
|
-
examples: ['moda loops --limit=10'],
|
|
1051
|
-
...dataApiDefaults,
|
|
1052
|
-
}),
|
|
1053
|
-
legacyCommand({
|
|
1054
|
-
name: 'loop',
|
|
1055
|
-
description: 'Get a single loop detection',
|
|
1056
|
-
examples: ['moda loop 12345678-1234-1234-1234-1234567890ab'],
|
|
1057
|
-
...dataApiDefaults,
|
|
1058
|
-
}),
|
|
1059
1026
|
legacyCommand({
|
|
1060
1027
|
name: 'prompts',
|
|
1061
1028
|
description: 'Manage prompt versions and labels',
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type AskStreamOutcome } from './ask-stream.js';
|
|
1
2
|
import type { CommandContext } from './command-context.js';
|
|
2
3
|
import type { AgentConfidence } from './output.js';
|
|
3
4
|
import { type ModaIntelligenceV1 } from './intelligence-schema.js';
|
|
@@ -5,10 +6,10 @@ declare const INVESTIGATION_SCHEMA_VERSION = "production_investigation.v0.1";
|
|
|
5
6
|
declare const OVERVIEW_SCHEMA_VERSION = "production_overview.v0.1";
|
|
6
7
|
declare const ASK_SCHEMA_VERSION = "production_ask.v0.1";
|
|
7
8
|
type Severity = 'critical' | 'high' | 'medium' | 'low' | 'info';
|
|
8
|
-
type FindingKind = 'tool_failure' | '
|
|
9
|
+
type FindingKind = 'tool_failure' | 'frustration' | 'prompt_regression' | 'skill_gap' | 'insufficient_data';
|
|
9
10
|
export interface ProductionEvidenceRef {
|
|
10
11
|
id: string;
|
|
11
|
-
kind: 'data_api' | 'tool_failure' | 'frustration' | '
|
|
12
|
+
kind: 'data_api' | 'tool_failure' | 'frustration' | 'harness' | 'ask';
|
|
12
13
|
label: string;
|
|
13
14
|
endpoint?: string;
|
|
14
15
|
conversationId?: string;
|
|
@@ -54,7 +55,6 @@ export interface ProductionInvestigation {
|
|
|
54
55
|
scope: {
|
|
55
56
|
conversationId?: string;
|
|
56
57
|
toolName?: string;
|
|
57
|
-
loopId?: string;
|
|
58
58
|
question?: string;
|
|
59
59
|
};
|
|
60
60
|
summary: {
|
|
@@ -137,7 +137,6 @@ export declare function investigateProduction(options: {
|
|
|
137
137
|
cwd: string;
|
|
138
138
|
conversationId?: string;
|
|
139
139
|
toolName?: string;
|
|
140
|
-
loopId?: string;
|
|
141
140
|
question?: string;
|
|
142
141
|
failureOnly?: boolean;
|
|
143
142
|
}): Promise<ProductionInvestigation>;
|
|
@@ -147,5 +146,20 @@ export declare function askModa(options: {
|
|
|
147
146
|
cwd: string;
|
|
148
147
|
profile?: string;
|
|
149
148
|
}): Promise<ProductionAskResult>;
|
|
149
|
+
/**
|
|
150
|
+
* Whether `moda ask` should use the live SSE endpoint: only for a human
|
|
151
|
+
* watching a TTY, and only when not explicitly opted out via `--no-stream`
|
|
152
|
+
* (or `--stream=false`). `--json`/`--agent`/piped/quiet callers always take
|
|
153
|
+
* the buffered path so machine-readable contracts are untouched.
|
|
154
|
+
*/
|
|
155
|
+
export declare function shouldStreamAsk(context: Pick<CommandContext, 'outputMode' | 'quiet' | 'flags'>, isTTY?: boolean): boolean;
|
|
156
|
+
/**
|
|
157
|
+
* Project a completed stream into the same `ProductionAskResult` the buffered
|
|
158
|
+
* path produces, so the final human render is identical. A terminal `error`
|
|
159
|
+
* event mirrors the buffered error contract (a generic answer with `unknown`
|
|
160
|
+
* confidence — the buffered endpoint also returns HTTP 200 in that case).
|
|
161
|
+
*/
|
|
162
|
+
export declare function askResultFromStreamOutcome(outcome: AskStreamOutcome, question: string): ProductionAskResult;
|
|
163
|
+
export declare function renderAskHuman(result: ProductionAskResult): string;
|
|
150
164
|
export {};
|
|
151
165
|
//# sourceMappingURL=production-intelligence.d.ts.map
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { callControlAPI, callDataAPI } from './api-client.js';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { streamAsk } from './ask-stream.js';
|
|
5
|
+
import { CliAuthError, CliInputError } from './errors.js';
|
|
6
|
+
import { EXIT_DEGRADED, EXIT_OK, safeWriteStderr } from './output.js';
|
|
6
7
|
import { asNumber, asRecord, asString } from './transforms.js';
|
|
7
8
|
import { INTELLIGENCE_SCHEMA_VERSION, } from './intelligence-schema.js';
|
|
8
9
|
const INVESTIGATION_SCHEMA_VERSION = 'production_investigation.v0.1';
|
|
@@ -32,7 +33,7 @@ export async function runProductionCommand(context) {
|
|
|
32
33
|
if (!question) {
|
|
33
34
|
throw new CliInputError('<question> is required', 'Usage: moda ask "what should I fix first?" [--days-back=N]');
|
|
34
35
|
}
|
|
35
|
-
const result = await
|
|
36
|
+
const result = await askModaForContext(context, question, daysBack);
|
|
36
37
|
if (context.outputMode === 'human') {
|
|
37
38
|
if (!context.quiet)
|
|
38
39
|
console.log(renderAskHuman(result));
|
|
@@ -66,7 +67,6 @@ export async function runProductionCommand(context) {
|
|
|
66
67
|
cwd: context.cwd,
|
|
67
68
|
conversationId: context.flags.conversation,
|
|
68
69
|
toolName: context.flags.tool,
|
|
69
|
-
loopId: context.flags.loop,
|
|
70
70
|
});
|
|
71
71
|
writeInvestigation(context, investigation);
|
|
72
72
|
return { exitCode: 0 };
|
|
@@ -127,16 +127,12 @@ export async function investigateProduction(options) {
|
|
|
127
127
|
? `/tool-failures/${encodeURIComponent(options.toolName)}?days_back=${options.daysBack}&limit=5`
|
|
128
128
|
: `/tool-failures?days_back=${options.daysBack}`;
|
|
129
129
|
const frustrationsEndpoint = `/frustrations?days_back=${options.daysBack}&limit=5`;
|
|
130
|
-
const
|
|
131
|
-
? `/loops/${encodeURIComponent(options.loopId)}`
|
|
132
|
-
: '/loops/active?limit=20';
|
|
133
|
-
const [overview, tools, frustrations, loops] = await Promise.all([
|
|
130
|
+
const [overview, tools, frustrations] = await Promise.all([
|
|
134
131
|
safeData(overviewEndpoint),
|
|
135
132
|
safeData(toolEndpoint),
|
|
136
133
|
safeData(frustrationsEndpoint),
|
|
137
|
-
safeData(loopsEndpoint),
|
|
138
134
|
]);
|
|
139
|
-
for (const [key, result] of Object.entries({ overview, tools, frustrations
|
|
135
|
+
for (const [key, result] of Object.entries({ overview, tools, frustrations })) {
|
|
140
136
|
sourceStatus[key] = result.status;
|
|
141
137
|
if (result.warning)
|
|
142
138
|
warnings.push(result.warning);
|
|
@@ -147,10 +143,6 @@ export async function investigateProduction(options) {
|
|
|
147
143
|
findings.push(...toolFailureFindings(tools.data, options.daysBack, hints, evidenceRefs, options.toolName));
|
|
148
144
|
if (!options.failureOnly) {
|
|
149
145
|
findings.push(...frustrationFindings(frustrations.data, options.daysBack, evidenceRefs));
|
|
150
|
-
findings.push(...loopFindings(loops.data, evidenceRefs, options.loopId));
|
|
151
|
-
}
|
|
152
|
-
else {
|
|
153
|
-
findings.push(...loopFindings(loops.data, evidenceRefs, options.loopId));
|
|
154
146
|
}
|
|
155
147
|
if (findings.length === 0) {
|
|
156
148
|
findings.push(insufficientDataFinding(overview.data, evidenceRefs));
|
|
@@ -165,7 +157,6 @@ export async function investigateProduction(options) {
|
|
|
165
157
|
scope: {
|
|
166
158
|
...(options.conversationId ? { conversationId: options.conversationId } : {}),
|
|
167
159
|
...(options.toolName ? { toolName: options.toolName } : {}),
|
|
168
|
-
...(options.loopId ? { loopId: options.loopId } : {}),
|
|
169
160
|
...(options.question ? { question: options.question } : {}),
|
|
170
161
|
},
|
|
171
162
|
summary: summarizeInvestigation(boundedFindings),
|
|
@@ -246,6 +237,98 @@ export async function askModa(options) {
|
|
|
246
237
|
}),
|
|
247
238
|
};
|
|
248
239
|
}
|
|
240
|
+
/**
|
|
241
|
+
* Whether `moda ask` should use the live SSE endpoint: only for a human
|
|
242
|
+
* watching a TTY, and only when not explicitly opted out via `--no-stream`
|
|
243
|
+
* (or `--stream=false`). `--json`/`--agent`/piped/quiet callers always take
|
|
244
|
+
* the buffered path so machine-readable contracts are untouched.
|
|
245
|
+
*/
|
|
246
|
+
export function shouldStreamAsk(context, isTTY = process.stdout.isTTY === true) {
|
|
247
|
+
if (context.outputMode !== 'human' || context.quiet)
|
|
248
|
+
return false;
|
|
249
|
+
if ('no-stream' in context.flags)
|
|
250
|
+
return false;
|
|
251
|
+
if (context.flags.stream === 'false')
|
|
252
|
+
return false;
|
|
253
|
+
return isTTY;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Human-TTY ask entrypoint: try the streaming endpoint for live progress and
|
|
257
|
+
* fall back to the buffered `askModa` on ANY stream problem (endpoint missing,
|
|
258
|
+
* connection dropped mid-run, framing error). The fallback is transparent —
|
|
259
|
+
* a note is printed only in verbose mode — so the outcome is never worse than
|
|
260
|
+
* the buffered path.
|
|
261
|
+
*/
|
|
262
|
+
async function askModaForContext(context, question, daysBack) {
|
|
263
|
+
if (shouldStreamAsk(context)) {
|
|
264
|
+
try {
|
|
265
|
+
const outcome = await streamAsk({
|
|
266
|
+
question,
|
|
267
|
+
daysBack,
|
|
268
|
+
profile: context.profile,
|
|
269
|
+
harness: localHarnessSummary(context.cwd),
|
|
270
|
+
timeoutMs: askTimeoutMs(),
|
|
271
|
+
onProgress: (line) => safeWriteStderr(`${line}\n`),
|
|
272
|
+
});
|
|
273
|
+
return askResultFromStreamOutcome(outcome, question);
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
// Auth problems must keep their structured exit-4 handling.
|
|
277
|
+
if (error instanceof CliAuthError)
|
|
278
|
+
throw error;
|
|
279
|
+
if (context.verbose) {
|
|
280
|
+
safeWriteStderr(`[moda] live progress unavailable (${error instanceof Error ? error.message : String(error)}); using buffered ask.\n`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return askModa({ question, daysBack, cwd: context.cwd, profile: context.profile });
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Project a completed stream into the same `ProductionAskResult` the buffered
|
|
288
|
+
* path produces, so the final human render is identical. A terminal `error`
|
|
289
|
+
* event mirrors the buffered error contract (a generic answer with `unknown`
|
|
290
|
+
* confidence — the buffered endpoint also returns HTTP 200 in that case).
|
|
291
|
+
*/
|
|
292
|
+
export function askResultFromStreamOutcome(outcome, question) {
|
|
293
|
+
const answer = outcome.errorMessage ?? outcome.answer;
|
|
294
|
+
const confidence = outcome.errorMessage
|
|
295
|
+
? 'unknown'
|
|
296
|
+
: normalizeConfidence(outcome.confidence);
|
|
297
|
+
const evidenceRefs = outcome.errorMessage
|
|
298
|
+
? []
|
|
299
|
+
: outcome.citations.map((citation) => ({
|
|
300
|
+
id: citation.id,
|
|
301
|
+
kind: evidenceKind(citation.source),
|
|
302
|
+
label: citation.label,
|
|
303
|
+
}));
|
|
304
|
+
const nextCommands = outcome.errorMessage ? [] : outcome.nextCommands;
|
|
305
|
+
const warnings = outcome.errorMessage
|
|
306
|
+
? ['The analysis agent failed; returning a generic message.']
|
|
307
|
+
: [];
|
|
308
|
+
return {
|
|
309
|
+
schemaVersion: ASK_SCHEMA_VERSION,
|
|
310
|
+
generatedAt: new Date().toISOString(),
|
|
311
|
+
source: 'cloud',
|
|
312
|
+
degraded: false,
|
|
313
|
+
question,
|
|
314
|
+
answer,
|
|
315
|
+
confidence,
|
|
316
|
+
evidenceRefs,
|
|
317
|
+
findings: [],
|
|
318
|
+
nextCommands,
|
|
319
|
+
warnings,
|
|
320
|
+
intelligence: synthesizeIntelligenceV1({
|
|
321
|
+
question,
|
|
322
|
+
answer,
|
|
323
|
+
confidence,
|
|
324
|
+
evidenceRefs,
|
|
325
|
+
nextCommands,
|
|
326
|
+
warnings,
|
|
327
|
+
source: 'cloud',
|
|
328
|
+
degraded: false,
|
|
329
|
+
}),
|
|
330
|
+
};
|
|
331
|
+
}
|
|
249
332
|
/**
|
|
250
333
|
* Timeout (ms) for the cloud `ask` POST. The backend enforces its own ~240s run
|
|
251
334
|
* deadline under a 300s function limit, so the client waits long enough to
|
|
@@ -444,44 +527,6 @@ function frustrationFindings(data, daysBack, evidenceRefs) {
|
|
|
444
527
|
}],
|
|
445
528
|
}];
|
|
446
529
|
}
|
|
447
|
-
function loopFindings(data, evidenceRefs, scopedLoopId) {
|
|
448
|
-
const rows = loopRows(data);
|
|
449
|
-
return rows.flatMap((loop) => {
|
|
450
|
-
const id = loop.id || scopedLoopId;
|
|
451
|
-
if (!id)
|
|
452
|
-
return [];
|
|
453
|
-
const evidenceId = `loop:${id}`;
|
|
454
|
-
evidenceRefs.push({
|
|
455
|
-
id: evidenceId,
|
|
456
|
-
kind: 'loop',
|
|
457
|
-
label: loop.signature || `${loop.type || 'Loop'} detected`,
|
|
458
|
-
endpoint: `/loops/${id}`,
|
|
459
|
-
detectionId: id,
|
|
460
|
-
conversationId: loop.conversationId,
|
|
461
|
-
value: loop.raw,
|
|
462
|
-
});
|
|
463
|
-
return [{
|
|
464
|
-
id: `finding_loop_${slug(id)}`,
|
|
465
|
-
kind: 'loop',
|
|
466
|
-
title: loop.signature || `${loop.type || 'Loop'} detected`,
|
|
467
|
-
severity: loop.blastRadius >= 5 ? 'high' : 'medium',
|
|
468
|
-
confidence: 'high',
|
|
469
|
-
impactScore: 84 + Math.min(loop.blastRadius, 10),
|
|
470
|
-
rankReason: `Active loop detection${loop.blastRadius ? ` with blast radius ${loop.blastRadius}` : ''}.`,
|
|
471
|
-
summary: loop.rootCause || 'The agent appears to repeat work or retry a failing path.',
|
|
472
|
-
evidenceRefIds: [evidenceId],
|
|
473
|
-
likelyLocations: [unknownLocation('prompt', 'Loops often come from retry policy, stop conditions, or tool handling.')],
|
|
474
|
-
recommendedActions: [{
|
|
475
|
-
id: `action_loop_${slug(id)}`,
|
|
476
|
-
title: 'Inspect loop detail.',
|
|
477
|
-
rationale: 'Loop detail includes signature, cycle length, and root-cause hypothesis.',
|
|
478
|
-
command: `moda loop ${id}`,
|
|
479
|
-
mutability: 'read',
|
|
480
|
-
requiresApproval: false,
|
|
481
|
-
}],
|
|
482
|
-
}];
|
|
483
|
-
});
|
|
484
|
-
}
|
|
485
530
|
function insufficientDataFinding(overview, evidenceRefs) {
|
|
486
531
|
const evidenceId = 'overview:data';
|
|
487
532
|
if (!evidenceRefs.some((ref) => ref.id === evidenceId)) {
|
|
@@ -974,7 +1019,7 @@ function renderInvestigationHuman(investigation) {
|
|
|
974
1019
|
}
|
|
975
1020
|
return lines.join('\n');
|
|
976
1021
|
}
|
|
977
|
-
function renderAskHuman(result) {
|
|
1022
|
+
export function renderAskHuman(result) {
|
|
978
1023
|
const lines = [];
|
|
979
1024
|
lines.push('Moda ask');
|
|
980
1025
|
lines.push(`Question: ${result.question}`);
|
|
@@ -1060,28 +1105,6 @@ function toolRows(data, scopedTool) {
|
|
|
1060
1105
|
}];
|
|
1061
1106
|
});
|
|
1062
1107
|
}
|
|
1063
|
-
function loopRows(data) {
|
|
1064
|
-
const record = asRecord(data);
|
|
1065
|
-
const rows = Array.isArray(record?.loops)
|
|
1066
|
-
? record.loops
|
|
1067
|
-
: asRecord(record)
|
|
1068
|
-
? [record]
|
|
1069
|
-
: [];
|
|
1070
|
-
return rows.flatMap((item) => {
|
|
1071
|
-
const row = asRecord(item);
|
|
1072
|
-
if (!row)
|
|
1073
|
-
return [];
|
|
1074
|
-
return [{
|
|
1075
|
-
id: asString(row.detection_id) ?? '',
|
|
1076
|
-
type: asString(row.loop_type) ?? undefined,
|
|
1077
|
-
signature: asString(row.loop_signature_human) ?? undefined,
|
|
1078
|
-
blastRadius: numberValue(row.blast_radius),
|
|
1079
|
-
conversationId: asString(row.conversation_id) ?? undefined,
|
|
1080
|
-
rootCause: asString(row.root_cause_hypothesis) ?? undefined,
|
|
1081
|
-
raw: row,
|
|
1082
|
-
}];
|
|
1083
|
-
}).filter((loop) => loop.id);
|
|
1084
|
-
}
|
|
1085
1108
|
function firstQuote(row) {
|
|
1086
1109
|
const quotes = Array.isArray(row?.user_quotes) ? row.user_quotes : [];
|
|
1087
1110
|
const first = asRecord(quotes[0]);
|
|
@@ -1335,7 +1358,6 @@ function evidenceKind(value) {
|
|
|
1335
1358
|
if (value === 'data_api' ||
|
|
1336
1359
|
value === 'tool_failure' ||
|
|
1337
1360
|
value === 'frustration' ||
|
|
1338
|
-
value === 'loop' ||
|
|
1339
1361
|
value === 'harness' ||
|
|
1340
1362
|
value === 'ask')
|
|
1341
1363
|
return value;
|
|
@@ -1355,8 +1377,6 @@ function purposeForCommand(command) {
|
|
|
1355
1377
|
return 'Inspect tool failure examples and conversation anchors.';
|
|
1356
1378
|
if (command.startsWith('moda context'))
|
|
1357
1379
|
return 'Read the relevant conversation window.';
|
|
1358
|
-
if (command.startsWith('moda loop'))
|
|
1359
|
-
return 'Inspect loop detection details.';
|
|
1360
1380
|
if (command.startsWith('moda doctor'))
|
|
1361
1381
|
return 'Validate Moda setup and data flow.';
|
|
1362
1382
|
if (command.startsWith('moda overview'))
|
package/dist/schemas.d.ts
CHANGED
|
@@ -185,20 +185,4 @@ export declare const SearchSchema: z.ZodObject<{
|
|
|
185
185
|
limit?: number | undefined;
|
|
186
186
|
user_id?: string | undefined;
|
|
187
187
|
}>;
|
|
188
|
-
/** moda loops — currently active loop detections. */
|
|
189
|
-
export declare const LoopsSchema: z.ZodObject<{
|
|
190
|
-
limit: z.ZodOptional<z.ZodDefault<z.ZodNumber>>;
|
|
191
|
-
}, "strip", z.ZodTypeAny, {
|
|
192
|
-
limit?: number | undefined;
|
|
193
|
-
}, {
|
|
194
|
-
limit?: number | undefined;
|
|
195
|
-
}>;
|
|
196
|
-
/** moda loop <detectionId> — single loop detection detail. */
|
|
197
|
-
export declare const LoopDetailSchema: z.ZodObject<{
|
|
198
|
-
detection_id: z.ZodString;
|
|
199
|
-
}, "strip", z.ZodTypeAny, {
|
|
200
|
-
detection_id: string;
|
|
201
|
-
}, {
|
|
202
|
-
detection_id: string;
|
|
203
|
-
}>;
|
|
204
188
|
//# sourceMappingURL=schemas.d.ts.map
|
package/dist/schemas.js
CHANGED
|
@@ -92,12 +92,4 @@ export const SearchSchema = z.object({
|
|
|
92
92
|
time_range: z.enum(TIME_RANGES).default('all').optional(),
|
|
93
93
|
limit: z.number().min(1).max(100).default(20).optional(),
|
|
94
94
|
});
|
|
95
|
-
/** moda loops — currently active loop detections. */
|
|
96
|
-
export const LoopsSchema = z.object({
|
|
97
|
-
limit: z.number().min(1).max(200).default(25).optional(),
|
|
98
|
-
});
|
|
99
|
-
/** moda loop <detectionId> — single loop detection detail. */
|
|
100
|
-
export const LoopDetailSchema = z.object({
|
|
101
|
-
detection_id: z.string().uuid({ message: 'detection_id must be a UUID' }),
|
|
102
|
-
});
|
|
103
95
|
//# sourceMappingURL=schemas.js.map
|
package/dist/tui.js
CHANGED
|
@@ -298,7 +298,6 @@ function overviewSignals(record) {
|
|
|
298
298
|
['Frustrations', valueAt(record, ['frustrations', 'total']) ?? record.frustrations],
|
|
299
299
|
['Runs', valueAt(record, ['runs', 'total']) ?? record.runs],
|
|
300
300
|
['Workflows', valueAt(record, ['workflows', 'total']) ?? record.workflows],
|
|
301
|
-
['Loops', valueAt(record, ['loops', 'active']) ?? valueAt(record, ['loops', 'total']) ?? record.loops],
|
|
302
301
|
];
|
|
303
302
|
return candidates
|
|
304
303
|
.map(([label, value]) => [label, metricText(value)])
|
package/package.json
CHANGED
package/skills/moda-cli/SKILL.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: moda-cli
|
|
3
3
|
version: 2.1.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,
|
|
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, 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, wants to find conversations or tool calls about a topic, or wants to investigate how their AI agent is performing.
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# Moda CLI
|
|
@@ -475,7 +475,7 @@ Data API fallback. Abbreviated envelope:
|
|
|
475
475
|
```
|
|
476
476
|
|
|
477
477
|
`moda investigate` also accepts scoping flags (`--tool`, `--run`,
|
|
478
|
-
`--conversation
|
|
478
|
+
`--conversation`) to focus the ranking on one signal.
|
|
479
479
|
|
|
480
480
|
### 3. Browse topic clusters
|
|
481
481
|
|
|
@@ -578,21 +578,7 @@ Pass `--include-window` to attach a `window` field per row with the message
|
|
|
578
578
|
slice centered on the anchor. `--window=N` (1–5, default `1`) is the
|
|
579
579
|
half-width; `--window=1` ⇒ 3 messages total (anchor ± 1).
|
|
580
580
|
|
|
581
|
-
### 7.
|
|
582
|
-
|
|
583
|
-
```bash
|
|
584
|
-
moda loops # everything currently stuck
|
|
585
|
-
moda loops --limit=10
|
|
586
|
-
moda loop <detection_id> # one loop signature in detail
|
|
587
|
-
```
|
|
588
|
-
|
|
589
|
-
Loops are detected in real time by the conversation worker (tier 0/1) and
|
|
590
|
-
the analytics container (tier 2). A row with `cycle_visits=378` means the
|
|
591
|
-
agent re-ran the same action 378 times. Use the `loop_signature_human`
|
|
592
|
-
field to recognize cross-conversation patterns (one tool definition bug
|
|
593
|
-
will produce identical signatures across many users).
|
|
594
|
-
|
|
595
|
-
### 8. Prompt management
|
|
581
|
+
### 7. Prompt management
|
|
596
582
|
|
|
597
583
|
Use these only when working on the code-first prompt registry.
|
|
598
584
|
|
|
@@ -762,8 +748,6 @@ npx: `npx -p @moda-ai/cli moda <command>`.
|
|
|
762
748
|
| `moda frustrations` | User frustration detections with evidence |
|
|
763
749
|
| `moda tool-failures` | Tool failure overview |
|
|
764
750
|
| `moda tool-failure-detail <tool_name>` | Per-tool failure breakdown + examples |
|
|
765
|
-
| `moda loops` | Currently-active loop detections |
|
|
766
|
-
| `moda loop <detection_id>` | One loop signature with blast radius |
|
|
767
751
|
| `moda prompts status` | Read-only local prompt status |
|
|
768
752
|
| `moda prompts diff` | Read-only local prompt diff/status |
|
|
769
753
|
| `moda prompts sync` | Upload changed prompt versions and update lockfile |
|