@moda-ai/cli 1.13.11 → 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.
@@ -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
@@ -203,6 +203,7 @@ Common flags:
203
203
  --agent Emit schema-versioned agent JSON
204
204
  --stream With --agent, emit NDJSON agent events
205
205
  --human Force human prose (overrides auto-detection)
206
+ --no-stream Disable live ask progress (buffered request)
206
207
 
207
208
  Output auto-detection (no flags needed):
208
209
  Piped/redirected stdout, CI, or an agent env (CLAUDECODE/CURSOR/MODA_AGENT)
@@ -940,7 +941,11 @@ export const commandRegistry = createCommandRegistry([
940
941
  legacyCommand({
941
942
  name: 'ask',
942
943
  description: 'Ask Moda a production intelligence question',
943
- examples: ['moda ask "what should I fix first?"', 'moda --agent ask "why did this run fail?"'],
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
+ ],
944
949
  ...dataApiDefaults,
945
950
  }),
946
951
  legacyCommand({
@@ -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';
@@ -145,5 +146,20 @@ export declare function askModa(options: {
145
146
  cwd: string;
146
147
  profile?: string;
147
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;
148
164
  export {};
149
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 { CliInputError } from './errors.js';
5
- import { EXIT_DEGRADED, EXIT_OK } from './output.js';
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 askModa({ question, daysBack, cwd: context.cwd, profile: context.profile });
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));
@@ -236,6 +237,98 @@ export async function askModa(options) {
236
237
  }),
237
238
  };
238
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
+ }
239
332
  /**
240
333
  * Timeout (ms) for the cloud `ask` POST. The backend enforces its own ~240s run
241
334
  * deadline under a 300s function limit, so the client waits long enough to
@@ -926,7 +1019,7 @@ function renderInvestigationHuman(investigation) {
926
1019
  }
927
1020
  return lines.join('\n');
928
1021
  }
929
- function renderAskHuman(result) {
1022
+ export function renderAskHuman(result) {
930
1023
  const lines = [];
931
1024
  lines.push('Moda ask');
932
1025
  lines.push(`Question: ${result.question}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moda-ai/cli",
3
- "version": "1.13.11",
3
+ "version": "1.14.0",
4
4
  "description": "CLI for Moda - AI agent analytics and observability",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema_version": "moda.skill_index.v1",
3
- "bundled_at": "2026-07-08T07:05:48.702Z",
4
- "cli_version": "1.13.11",
3
+ "bundled_at": "2026-07-08T09:14:05.529Z",
4
+ "cli_version": "1.14.0",
5
5
  "skills": [
6
6
  {
7
7
  "id": "integration-node-anthropic",