@moda-ai/cli 1.15.1 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api-client.d.ts +24 -0
- package/dist/api-client.js +43 -9
- package/dist/ask-stream.d.ts +57 -1
- package/dist/ask-stream.js +151 -8
- package/dist/markdown-terminal.d.ts +49 -0
- package/dist/markdown-terminal.js +212 -0
- package/dist/production-intelligence.d.ts +5 -1
- package/dist/production-intelligence.js +53 -17
- package/package.json +1 -1
- package/skills/integration/index.json +2 -2
package/dist/api-client.d.ts
CHANGED
|
@@ -4,6 +4,16 @@ export declare class ApiError extends Error {
|
|
|
4
4
|
readonly responseBody: string;
|
|
5
5
|
constructor(message: string, statusCode: number, responseBody: string);
|
|
6
6
|
}
|
|
7
|
+
/**
|
|
8
|
+
* Transport-level failure: the socket died before any HTTP response existed
|
|
9
|
+
* (`fetch failed`, ECONNRESET, …). Distinct from `ApiError` (an HTTP status)
|
|
10
|
+
* and from the per-attempt timeout so callers can retry ONLY genuine network
|
|
11
|
+
* faults — e.g. undici handing out a pooled connection poisoned by a
|
|
12
|
+
* just-dropped SSE stream on the same origin.
|
|
13
|
+
*/
|
|
14
|
+
export declare class NetworkError extends Error {
|
|
15
|
+
constructor(message: string);
|
|
16
|
+
}
|
|
7
17
|
export declare function callDataAPI(endpoint: string): Promise<unknown>;
|
|
8
18
|
/**
|
|
9
19
|
* Per-call transport overrides. Used by expensive, long-running, non-idempotent
|
|
@@ -15,6 +25,20 @@ export interface ModaRequestOptions {
|
|
|
15
25
|
timeoutMs?: number;
|
|
16
26
|
/** Max retries (default MAX_RETRIES). Set 0 to disable retries entirely. */
|
|
17
27
|
retries?: number;
|
|
28
|
+
/**
|
|
29
|
+
* Retry exactly once more on a NETWORK-level failure, even with `retries: 0`.
|
|
30
|
+
* After a streamed SSE connection dies, undici's per-origin pool can hand
|
|
31
|
+
* the very next fetch the same dead socket — the buffered fallback then
|
|
32
|
+
* fails instantly ("fetch failed") against a healthy backend. The retry
|
|
33
|
+
* waits ~1s and sends `Connection: close` so it cannot reuse a pooled
|
|
34
|
+
* connection. HTTP errors (`ApiError`) and the per-attempt timeout NEVER
|
|
35
|
+
* consume this retry.
|
|
36
|
+
*/
|
|
37
|
+
retryOnceOnNetworkError?: boolean;
|
|
38
|
+
/** Delay before the network retry (default 1s; test seam). */
|
|
39
|
+
networkRetryDelayMs?: number;
|
|
40
|
+
/** Invoked when the network retry is consumed (callers may log a note). */
|
|
41
|
+
onNetworkRetry?: () => void;
|
|
18
42
|
}
|
|
19
43
|
export declare function callControlAPI(endpoint: string, options?: RequestInit, profileOptions?: ProfileIOOptions, requestOptions?: ModaRequestOptions): Promise<unknown>;
|
|
20
44
|
export declare function getApiRequestCount(): number;
|
package/dist/api-client.js
CHANGED
|
@@ -3,6 +3,7 @@ import { resolveApiKey, resolveModaBaseUrl } from './config.js';
|
|
|
3
3
|
const REQUEST_TIMEOUT_MS = 30_000;
|
|
4
4
|
const MAX_RETRIES = 3;
|
|
5
5
|
const INITIAL_BACKOFF_MS = 500;
|
|
6
|
+
const NETWORK_RETRY_DELAY_MS = 1_000;
|
|
6
7
|
let apiRequestCount = 0;
|
|
7
8
|
export class ApiError extends Error {
|
|
8
9
|
statusCode;
|
|
@@ -14,6 +15,19 @@ export class ApiError extends Error {
|
|
|
14
15
|
this.name = 'ApiError';
|
|
15
16
|
}
|
|
16
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* Transport-level failure: the socket died before any HTTP response existed
|
|
20
|
+
* (`fetch failed`, ECONNRESET, …). Distinct from `ApiError` (an HTTP status)
|
|
21
|
+
* and from the per-attempt timeout so callers can retry ONLY genuine network
|
|
22
|
+
* faults — e.g. undici handing out a pooled connection poisoned by a
|
|
23
|
+
* just-dropped SSE stream on the same origin.
|
|
24
|
+
*/
|
|
25
|
+
export class NetworkError extends Error {
|
|
26
|
+
constructor(message) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = 'NetworkError';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
17
31
|
function isRetryable(status) {
|
|
18
32
|
return status === 429 || status >= 500;
|
|
19
33
|
}
|
|
@@ -29,10 +43,23 @@ export async function callControlAPI(endpoint, options = {}, profileOptions = {}
|
|
|
29
43
|
async function callModaAPI(url, options, profileOptions = {}, requestOptions = {}) {
|
|
30
44
|
const timeoutMs = requestOptions.timeoutMs ?? REQUEST_TIMEOUT_MS;
|
|
31
45
|
const maxRetries = requestOptions.retries ?? MAX_RETRIES;
|
|
32
|
-
|
|
33
|
-
|
|
46
|
+
const initialNetworkRetryBudget = requestOptions.retryOnceOnNetworkError
|
|
47
|
+
? 1
|
|
48
|
+
: 0;
|
|
49
|
+
let networkRetryBudget = initialNetworkRetryBudget;
|
|
50
|
+
// Set once the network-retry budget is consumed: the retry attempt (and any
|
|
51
|
+
// later ones) must not reuse a possibly-poisoned pooled connection.
|
|
52
|
+
let connectionClose = false;
|
|
53
|
+
// Structural termination bound: (maxRetries + 1) normal attempts plus at most
|
|
54
|
+
// one extra network-retry attempt. Every path inside returns, throws, or
|
|
55
|
+
// `continue`s, so the trailing throw is unreachable — but the bound makes the
|
|
56
|
+
// contract hold by construction rather than by manual path analysis.
|
|
57
|
+
const maxAttempts = maxRetries + initialNetworkRetryBudget + 1;
|
|
58
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
34
59
|
if (attempt > 0) {
|
|
35
|
-
const backoff =
|
|
60
|
+
const backoff = connectionClose
|
|
61
|
+
? requestOptions.networkRetryDelayMs ?? NETWORK_RETRY_DELAY_MS
|
|
62
|
+
: INITIAL_BACKOFF_MS * Math.pow(2, attempt - 1);
|
|
36
63
|
await new Promise((resolve) => setTimeout(resolve, backoff));
|
|
37
64
|
}
|
|
38
65
|
const controller = new AbortController();
|
|
@@ -44,6 +71,7 @@ async function callModaAPI(url, options, profileOptions = {}, requestOptions = {
|
|
|
44
71
|
'x-api-key': resolveApiKey(profileOptions) ?? '',
|
|
45
72
|
'Content-Type': 'application/json',
|
|
46
73
|
...(options.headers ?? {}),
|
|
74
|
+
...(connectionClose ? { Connection: 'close' } : {}),
|
|
47
75
|
},
|
|
48
76
|
signal: controller.signal,
|
|
49
77
|
});
|
|
@@ -51,7 +79,6 @@ async function callModaAPI(url, options, profileOptions = {}, requestOptions = {
|
|
|
51
79
|
const errorText = await response.text();
|
|
52
80
|
const error = new ApiError(`API error (${response.status}): ${errorText}`, response.status, errorText);
|
|
53
81
|
if (isRetryable(response.status) && attempt < maxRetries) {
|
|
54
|
-
lastError = error;
|
|
55
82
|
continue;
|
|
56
83
|
}
|
|
57
84
|
throw error;
|
|
@@ -62,11 +89,16 @@ async function callModaAPI(url, options, profileOptions = {}, requestOptions = {
|
|
|
62
89
|
if (error instanceof ApiError)
|
|
63
90
|
throw error;
|
|
64
91
|
const isAbort = error instanceof DOMException && error.name === 'AbortError';
|
|
65
|
-
const wrapped =
|
|
66
|
-
? `Request timed out after ${timeoutMs}ms: ${url}`
|
|
67
|
-
: `Network error: ${error instanceof Error ? error.message : String(error)}`);
|
|
92
|
+
const wrapped = isAbort
|
|
93
|
+
? new Error(`Request timed out after ${timeoutMs}ms: ${url}`)
|
|
94
|
+
: new NetworkError(`Network error: ${error instanceof Error ? error.message : String(error)}`);
|
|
68
95
|
if (attempt < maxRetries) {
|
|
69
|
-
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (!isAbort && networkRetryBudget > 0) {
|
|
99
|
+
networkRetryBudget -= 1;
|
|
100
|
+
connectionClose = true;
|
|
101
|
+
requestOptions.onNetworkRetry?.();
|
|
70
102
|
continue;
|
|
71
103
|
}
|
|
72
104
|
throw wrapped;
|
|
@@ -75,7 +107,9 @@ async function callModaAPI(url, options, profileOptions = {}, requestOptions = {
|
|
|
75
107
|
clearTimeout(timeout);
|
|
76
108
|
}
|
|
77
109
|
}
|
|
78
|
-
|
|
110
|
+
// Unreachable: the loop above always returns or throws within maxAttempts.
|
|
111
|
+
// Present so the bound is enforced by construction, not just documented.
|
|
112
|
+
throw new NetworkError(`Request retries exhausted: ${url}`);
|
|
79
113
|
}
|
|
80
114
|
export function getApiRequestCount() {
|
|
81
115
|
return apiRequestCount;
|
package/dist/ask-stream.d.ts
CHANGED
|
@@ -25,8 +25,29 @@ export interface AskStreamOutcome {
|
|
|
25
25
|
}
|
|
26
26
|
/** Signals "fall back to the buffered ask endpoint" — never surfaced raw. */
|
|
27
27
|
export declare class AskStreamError extends Error {
|
|
28
|
+
/** Wall-clock ms the stream ran before failing (annotated by `streamAsk`). */
|
|
29
|
+
elapsedMs?: number;
|
|
30
|
+
/** tool_start/tool_end events seen before failing (annotated by `streamAsk`). */
|
|
31
|
+
toolEvents?: number;
|
|
28
32
|
constructor(message: string);
|
|
29
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Thresholds for "substantial progress": a stream that ran at least this long,
|
|
36
|
+
* or produced more tool events than this, did real work before dropping —
|
|
37
|
+
* the buffered fallback then re-runs the WHOLE question, roughly doubling
|
|
38
|
+
* total latency, so the user deserves a notice instead of silence. The elapsed
|
|
39
|
+
* check is inclusive (`>=`) so a drop landing exactly on the boundary — where
|
|
40
|
+
* the doubled wait is felt most acutely — still gets the notice.
|
|
41
|
+
*/
|
|
42
|
+
export declare const SUBSTANTIAL_STREAM_PROGRESS_MS = 60000;
|
|
43
|
+
export declare const SUBSTANTIAL_STREAM_TOOL_EVENTS = 5;
|
|
44
|
+
/**
|
|
45
|
+
* One-line stderr notice for a stream that died after substantial progress.
|
|
46
|
+
* Returns null for early failures (they keep today's silent fallback) and for
|
|
47
|
+
* non-stream errors. The caller still falls back either way — the user must
|
|
48
|
+
* still get an answer, just an informed wait instead of an apparent hang.
|
|
49
|
+
*/
|
|
50
|
+
export declare function streamFallbackNotice(error: unknown): string | null;
|
|
30
51
|
/**
|
|
31
52
|
* Incremental SSE frame parser. Feed decoded text chunks (arbitrarily split —
|
|
32
53
|
* even mid `data:` line or mid multi-byte frame); complete `data:` payloads
|
|
@@ -40,6 +61,33 @@ export declare class SseParser {
|
|
|
40
61
|
/** Drain a final frame that was not terminated by a blank line. */
|
|
41
62
|
flush(): string[];
|
|
42
63
|
}
|
|
64
|
+
/** Quiet period before the first liveness line is printed. */
|
|
65
|
+
export declare const IDLE_TICKER_THRESHOLD_MS = 10000;
|
|
66
|
+
/** Minimum spacing between liveness lines. */
|
|
67
|
+
export declare const IDLE_TICKER_MIN_UPDATE_MS = 15000;
|
|
68
|
+
/**
|
|
69
|
+
* Client-side liveness for quiet stream periods: long tool-less LLM turns
|
|
70
|
+
* (coverage gate, synthesis) emit no events for 30–90s, which reads as a
|
|
71
|
+
* hang. Once no event has arrived for IDLE_TICKER_THRESHOLD_MS this prints an
|
|
72
|
+
* unobtrusive "… still working (Ns)" line (at most every
|
|
73
|
+
* IDLE_TICKER_MIN_UPDATE_MS) through the same progress channel as tool cards —
|
|
74
|
+
* stderr only, never interleaved into the final answer.
|
|
75
|
+
*/
|
|
76
|
+
export declare class IdleTicker {
|
|
77
|
+
private readonly write;
|
|
78
|
+
private readonly now;
|
|
79
|
+
private startedAt;
|
|
80
|
+
private lastEventAt;
|
|
81
|
+
private lastPrintAt;
|
|
82
|
+
private timer;
|
|
83
|
+
constructor(write: (line: string) => void, now?: () => number);
|
|
84
|
+
start(): void;
|
|
85
|
+
/** Reset the idle clock — call on every real stream event. */
|
|
86
|
+
touch(): void;
|
|
87
|
+
stop(): void;
|
|
88
|
+
/** One liveness check; driven by the interval (public for tests). */
|
|
89
|
+
poll(): void;
|
|
90
|
+
}
|
|
43
91
|
/**
|
|
44
92
|
* Consumes parsed stream events: renders live progress lines through
|
|
45
93
|
* `onProgress` and accumulates the final outcome. `finish()` throws
|
|
@@ -58,9 +106,12 @@ export declare class AskStreamCollector {
|
|
|
58
106
|
private terminal;
|
|
59
107
|
private lastPhase;
|
|
60
108
|
private toolNames;
|
|
109
|
+
private toolEvents;
|
|
61
110
|
constructor(onProgress?: (line: string) => void);
|
|
62
111
|
handle(raw: unknown): void;
|
|
63
112
|
isTerminal(): boolean;
|
|
113
|
+
/** tool_start/tool_end events seen so far (fallback-heuristic input). */
|
|
114
|
+
get toolEventCount(): number;
|
|
64
115
|
finish(): AskStreamOutcome;
|
|
65
116
|
}
|
|
66
117
|
/**
|
|
@@ -68,7 +119,12 @@ export declare class AskStreamCollector {
|
|
|
68
119
|
* parser into the collector. Exported separately from `streamAsk` so tests can
|
|
69
120
|
* exercise chunk-splitting behavior without a network layer.
|
|
70
121
|
*/
|
|
71
|
-
export declare function consumeAskSse(chunks: AsyncIterable<Uint8Array | string>, collector: AskStreamCollector
|
|
122
|
+
export declare function consumeAskSse(chunks: AsyncIterable<Uint8Array | string>, collector: AskStreamCollector,
|
|
123
|
+
/**
|
|
124
|
+
* Fires once per parsed `data:` event (keepalive comment frames never reach
|
|
125
|
+
* it). Used to reset the idle liveness ticker on real progress.
|
|
126
|
+
*/
|
|
127
|
+
onEvent?: () => void): Promise<AskStreamOutcome>;
|
|
72
128
|
export interface StreamAskOptions {
|
|
73
129
|
question: string;
|
|
74
130
|
daysBack: number;
|
package/dist/ask-stream.js
CHANGED
|
@@ -13,13 +13,46 @@
|
|
|
13
13
|
// failure: it is returned as an outcome (`errorMessage`) and rendered the same
|
|
14
14
|
// way buffered agent errors render.
|
|
15
15
|
import { resolveApiKey, resolveModaBaseUrl } from './config.js';
|
|
16
|
+
import { stripAnsi } from './markdown-terminal.js';
|
|
16
17
|
/** Signals "fall back to the buffered ask endpoint" — never surfaced raw. */
|
|
17
18
|
export class AskStreamError extends Error {
|
|
19
|
+
/** Wall-clock ms the stream ran before failing (annotated by `streamAsk`). */
|
|
20
|
+
elapsedMs;
|
|
21
|
+
/** tool_start/tool_end events seen before failing (annotated by `streamAsk`). */
|
|
22
|
+
toolEvents;
|
|
18
23
|
constructor(message) {
|
|
19
24
|
super(message);
|
|
20
25
|
this.name = 'AskStreamError';
|
|
21
26
|
}
|
|
22
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* Thresholds for "substantial progress": a stream that ran at least this long,
|
|
30
|
+
* or produced more tool events than this, did real work before dropping —
|
|
31
|
+
* the buffered fallback then re-runs the WHOLE question, roughly doubling
|
|
32
|
+
* total latency, so the user deserves a notice instead of silence. The elapsed
|
|
33
|
+
* check is inclusive (`>=`) so a drop landing exactly on the boundary — where
|
|
34
|
+
* the doubled wait is felt most acutely — still gets the notice.
|
|
35
|
+
*/
|
|
36
|
+
export const SUBSTANTIAL_STREAM_PROGRESS_MS = 60_000;
|
|
37
|
+
export const SUBSTANTIAL_STREAM_TOOL_EVENTS = 5;
|
|
38
|
+
/**
|
|
39
|
+
* One-line stderr notice for a stream that died after substantial progress.
|
|
40
|
+
* Returns null for early failures (they keep today's silent fallback) and for
|
|
41
|
+
* non-stream errors. The caller still falls back either way — the user must
|
|
42
|
+
* still get an answer, just an informed wait instead of an apparent hang.
|
|
43
|
+
*/
|
|
44
|
+
export function streamFallbackNotice(error) {
|
|
45
|
+
if (!(error instanceof AskStreamError))
|
|
46
|
+
return null;
|
|
47
|
+
const elapsedMs = error.elapsedMs ?? 0;
|
|
48
|
+
const toolEvents = error.toolEvents ?? 0;
|
|
49
|
+
if (elapsedMs < SUBSTANTIAL_STREAM_PROGRESS_MS &&
|
|
50
|
+
toolEvents <= SUBSTANTIAL_STREAM_TOOL_EVENTS) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
return (`stream interrupted after ${Math.round(elapsedMs / 1000)}s; ` +
|
|
54
|
+
'retrying via buffered request — this may take another minute or two.');
|
|
55
|
+
}
|
|
23
56
|
/**
|
|
24
57
|
* Incremental SSE frame parser. Feed decoded text chunks (arbitrarily split —
|
|
25
58
|
* even mid `data:` line or mid multi-byte frame); complete `data:` payloads
|
|
@@ -73,6 +106,68 @@ function dataPayloadOf(frame) {
|
|
|
73
106
|
}
|
|
74
107
|
/** Phases worth a human status line (terminal/idle phases are omitted). */
|
|
75
108
|
const RENDERED_PHASES = new Set(['planning', 'searching', 'analyzing', 'writing']);
|
|
109
|
+
// ─── Idle liveness ticker ────────────────────────────────────
|
|
110
|
+
/** Quiet period before the first liveness line is printed. */
|
|
111
|
+
export const IDLE_TICKER_THRESHOLD_MS = 10_000;
|
|
112
|
+
/** Minimum spacing between liveness lines. */
|
|
113
|
+
export const IDLE_TICKER_MIN_UPDATE_MS = 15_000;
|
|
114
|
+
const IDLE_TICKER_POLL_MS = 1_000;
|
|
115
|
+
/**
|
|
116
|
+
* Client-side liveness for quiet stream periods: long tool-less LLM turns
|
|
117
|
+
* (coverage gate, synthesis) emit no events for 30–90s, which reads as a
|
|
118
|
+
* hang. Once no event has arrived for IDLE_TICKER_THRESHOLD_MS this prints an
|
|
119
|
+
* unobtrusive "… still working (Ns)" line (at most every
|
|
120
|
+
* IDLE_TICKER_MIN_UPDATE_MS) through the same progress channel as tool cards —
|
|
121
|
+
* stderr only, never interleaved into the final answer.
|
|
122
|
+
*/
|
|
123
|
+
export class IdleTicker {
|
|
124
|
+
write;
|
|
125
|
+
now;
|
|
126
|
+
startedAt;
|
|
127
|
+
lastEventAt;
|
|
128
|
+
lastPrintAt = 0;
|
|
129
|
+
timer;
|
|
130
|
+
constructor(write, now = Date.now) {
|
|
131
|
+
this.write = write;
|
|
132
|
+
this.now = now;
|
|
133
|
+
this.startedAt = this.now();
|
|
134
|
+
this.lastEventAt = this.startedAt;
|
|
135
|
+
}
|
|
136
|
+
start() {
|
|
137
|
+
if (this.timer)
|
|
138
|
+
return;
|
|
139
|
+
this.startedAt = this.now();
|
|
140
|
+
this.lastEventAt = this.startedAt;
|
|
141
|
+
this.timer = setInterval(() => this.poll(), IDLE_TICKER_POLL_MS);
|
|
142
|
+
// Never hold the process open for a liveness timer.
|
|
143
|
+
this.timer.unref?.();
|
|
144
|
+
}
|
|
145
|
+
/** Reset the idle clock — call on every real stream event. */
|
|
146
|
+
touch() {
|
|
147
|
+
this.lastEventAt = this.now();
|
|
148
|
+
}
|
|
149
|
+
stop() {
|
|
150
|
+
if (this.timer !== undefined)
|
|
151
|
+
clearInterval(this.timer);
|
|
152
|
+
this.timer = undefined;
|
|
153
|
+
}
|
|
154
|
+
/** One liveness check; driven by the interval (public for tests). */
|
|
155
|
+
poll() {
|
|
156
|
+
const at = this.now();
|
|
157
|
+
if (at - this.lastEventAt < IDLE_TICKER_THRESHOLD_MS)
|
|
158
|
+
return;
|
|
159
|
+
if (this.lastPrintAt !== 0 && at - this.lastPrintAt < IDLE_TICKER_MIN_UPDATE_MS)
|
|
160
|
+
return;
|
|
161
|
+
this.lastPrintAt = at;
|
|
162
|
+
const secs = Math.round((at - this.startedAt) / 1000);
|
|
163
|
+
try {
|
|
164
|
+
this.write(`… still working (${secs}s)`);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
/* a liveness line must never break the stream */
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
76
171
|
/**
|
|
77
172
|
* Consumes parsed stream events: renders live progress lines through
|
|
78
173
|
* `onProgress` and accumulates the final outcome. `finish()` throws
|
|
@@ -91,6 +186,7 @@ export class AskStreamCollector {
|
|
|
91
186
|
terminal = false;
|
|
92
187
|
lastPhase;
|
|
93
188
|
toolNames = new Map();
|
|
189
|
+
toolEvents = 0;
|
|
94
190
|
constructor(onProgress = () => { }) {
|
|
95
191
|
this.onProgress = onProgress;
|
|
96
192
|
}
|
|
@@ -110,20 +206,35 @@ export class AskStreamCollector {
|
|
|
110
206
|
}
|
|
111
207
|
break;
|
|
112
208
|
}
|
|
113
|
-
case 'thinking':
|
|
114
|
-
// Model narration is too chatty for one-line terminal
|
|
209
|
+
case 'thinking': {
|
|
210
|
+
// Model narration (`t<N>` ids) is too chatty for one-line terminal
|
|
211
|
+
// progress and stays hidden. Deliberation status lines the backend
|
|
212
|
+
// emits on purpose ('plan', 'gate-*': multi-part plan, coverage
|
|
213
|
+
// checks, nudge cycles) are concise and cover otherwise-silent LLM
|
|
214
|
+
// calls — render them like tool cards so the gate never reads as a hang.
|
|
215
|
+
const id = typeof event.id === 'string' ? event.id : '';
|
|
216
|
+
if (id === 'plan' || id.startsWith('gate-')) {
|
|
217
|
+
const delta = typeof event.delta === 'string' ? event.delta : '';
|
|
218
|
+
if (delta.trim().length > 0)
|
|
219
|
+
this.onProgress(`◦ ${oneLine(delta)}`);
|
|
220
|
+
}
|
|
115
221
|
break;
|
|
222
|
+
}
|
|
116
223
|
case 'tool_start': {
|
|
224
|
+
this.toolEvents += 1;
|
|
117
225
|
const tool = event.tool;
|
|
118
226
|
const id = typeof tool?.id === 'string' ? tool.id : '';
|
|
119
|
-
|
|
120
|
-
|
|
227
|
+
// Backend-controlled strings — strip ANSI so a rogue/compromised backend
|
|
228
|
+
// (or prompt-injected LLM output) can't inject terminal control sequences.
|
|
229
|
+
const name = stripAnsi(typeof tool?.tool === 'string' ? tool.tool : 'tool');
|
|
230
|
+
const title = stripAnsi(typeof tool?.title === 'string' ? tool.title : name);
|
|
121
231
|
if (id)
|
|
122
232
|
this.toolNames.set(id, name);
|
|
123
233
|
this.onProgress(`• ${name} — ${title}…`);
|
|
124
234
|
break;
|
|
125
235
|
}
|
|
126
236
|
case 'tool_end': {
|
|
237
|
+
this.toolEvents += 1;
|
|
127
238
|
const id = typeof event.id === 'string' ? event.id : '';
|
|
128
239
|
const name = this.toolNames.get(id) ?? 'tool';
|
|
129
240
|
this.onProgress(`• ${name} — ${toolEndDetail(event)}`);
|
|
@@ -165,6 +276,10 @@ export class AskStreamCollector {
|
|
|
165
276
|
isTerminal() {
|
|
166
277
|
return this.terminal;
|
|
167
278
|
}
|
|
279
|
+
/** tool_start/tool_end events seen so far (fallback-heuristic input). */
|
|
280
|
+
get toolEventCount() {
|
|
281
|
+
return this.toolEvents;
|
|
282
|
+
}
|
|
168
283
|
finish() {
|
|
169
284
|
if (!this.terminal) {
|
|
170
285
|
throw new AskStreamError('stream ended before a terminal done/error event');
|
|
@@ -191,7 +306,9 @@ export class AskStreamCollector {
|
|
|
191
306
|
*/
|
|
192
307
|
const TOOL_CARD_DETAIL_MAX = 100;
|
|
193
308
|
function oneLine(text) {
|
|
194
|
-
|
|
309
|
+
// Strip ANSI first: the source is backend-controlled tool summaries/errors,
|
|
310
|
+
// and ESC bytes survive the whitespace collapse below.
|
|
311
|
+
const collapsed = stripAnsi(text).replace(/\s+/g, ' ').trim();
|
|
195
312
|
return collapsed.length > TOOL_CARD_DETAIL_MAX
|
|
196
313
|
? `${collapsed.slice(0, TOOL_CARD_DETAIL_MAX - 1)}…`
|
|
197
314
|
: collapsed;
|
|
@@ -241,19 +358,26 @@ function normalizeStreamConfidence(value) {
|
|
|
241
358
|
* parser into the collector. Exported separately from `streamAsk` so tests can
|
|
242
359
|
* exercise chunk-splitting behavior without a network layer.
|
|
243
360
|
*/
|
|
244
|
-
export async function consumeAskSse(chunks, collector
|
|
361
|
+
export async function consumeAskSse(chunks, collector,
|
|
362
|
+
/**
|
|
363
|
+
* Fires once per parsed `data:` event (keepalive comment frames never reach
|
|
364
|
+
* it). Used to reset the idle liveness ticker on real progress.
|
|
365
|
+
*/
|
|
366
|
+
onEvent) {
|
|
245
367
|
const parser = new SseParser();
|
|
246
368
|
const decoder = new TextDecoder();
|
|
247
369
|
for await (const chunk of chunks) {
|
|
248
370
|
const text = typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true });
|
|
249
371
|
for (const payload of parser.push(text)) {
|
|
250
372
|
collector.handle(parsePayload(payload));
|
|
373
|
+
onEvent?.();
|
|
251
374
|
if (collector.isTerminal())
|
|
252
375
|
return collector.finish();
|
|
253
376
|
}
|
|
254
377
|
}
|
|
255
378
|
for (const payload of parser.flush()) {
|
|
256
379
|
collector.handle(parsePayload(payload));
|
|
380
|
+
onEvent?.();
|
|
257
381
|
if (collector.isTerminal())
|
|
258
382
|
return collector.finish();
|
|
259
383
|
}
|
|
@@ -279,6 +403,13 @@ export async function streamAsk(options) {
|
|
|
279
403
|
const url = `${resolveModaBaseUrl(profileOptions)}/api/cli/ask/stream`;
|
|
280
404
|
const controller = new AbortController();
|
|
281
405
|
const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? DEFAULT_STREAM_TIMEOUT_MS);
|
|
406
|
+
const startedAt = Date.now();
|
|
407
|
+
const onProgress = options.onProgress ?? (() => { });
|
|
408
|
+
const collector = new AskStreamCollector(onProgress);
|
|
409
|
+
// Liveness lines share the progress channel (stderr in the CLI), so they
|
|
410
|
+
// can never interleave into the final stdout answer.
|
|
411
|
+
const ticker = new IdleTicker(onProgress);
|
|
412
|
+
ticker.start();
|
|
282
413
|
try {
|
|
283
414
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
284
415
|
let response;
|
|
@@ -313,9 +444,8 @@ export async function streamAsk(options) {
|
|
|
313
444
|
if (!response.body) {
|
|
314
445
|
throw new AskStreamError('stream response had no body');
|
|
315
446
|
}
|
|
316
|
-
const collector = new AskStreamCollector(options.onProgress);
|
|
317
447
|
try {
|
|
318
|
-
return await consumeAskSse(response.body, collector);
|
|
448
|
+
return await consumeAskSse(response.body, collector, () => ticker.touch());
|
|
319
449
|
}
|
|
320
450
|
catch (error) {
|
|
321
451
|
if (error instanceof AskStreamError)
|
|
@@ -323,7 +453,20 @@ export async function streamAsk(options) {
|
|
|
323
453
|
throw new AskStreamError(`stream read failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
324
454
|
}
|
|
325
455
|
}
|
|
456
|
+
catch (error) {
|
|
457
|
+
// Annotate stream failures with how far the run got, so the caller can
|
|
458
|
+
// decide between today's silent fallback (early drop) and an informed
|
|
459
|
+
// one (drop after substantial progress). See `streamFallbackNotice`.
|
|
460
|
+
if (error instanceof AskStreamError) {
|
|
461
|
+
if (error.elapsedMs === undefined)
|
|
462
|
+
error.elapsedMs = Date.now() - startedAt;
|
|
463
|
+
if (error.toolEvents === undefined)
|
|
464
|
+
error.toolEvents = collector.toolEventCount;
|
|
465
|
+
}
|
|
466
|
+
throw error;
|
|
467
|
+
}
|
|
326
468
|
finally {
|
|
469
|
+
ticker.stop();
|
|
327
470
|
clearTimeout(timeout);
|
|
328
471
|
}
|
|
329
472
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export interface MarkdownTerminalOptions {
|
|
2
|
+
/** When false, all structural fixes still apply but zero ANSI codes are emitted. */
|
|
3
|
+
color: boolean;
|
|
4
|
+
}
|
|
5
|
+
/** Minimal ANSI styling helpers shared by the renderer and ask chrome. */
|
|
6
|
+
export interface TerminalStyles {
|
|
7
|
+
bold(text: string): string;
|
|
8
|
+
italic(text: string): string;
|
|
9
|
+
dim(text: string): string;
|
|
10
|
+
code(text: string): string;
|
|
11
|
+
/** Section heading: bold + underline. */
|
|
12
|
+
heading(text: string): string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The CLI's color convention (mirrors `src/tui.ts` / `src/analyst-tui.ts`):
|
|
16
|
+
* color only for a human at a TTY, honoring `NO_COLOR` (present with ANY value,
|
|
17
|
+
* including the empty string, disables — per the no-color.org spec) and
|
|
18
|
+
* `TERM=dumb`.
|
|
19
|
+
*/
|
|
20
|
+
export declare function shouldColorTerminalOutput(opts?: {
|
|
21
|
+
isTTY?: boolean;
|
|
22
|
+
env?: NodeJS.ProcessEnv;
|
|
23
|
+
}): boolean;
|
|
24
|
+
/**
|
|
25
|
+
* ANSI style constructors. Closers restore the specific attribute (22/23/39)
|
|
26
|
+
* rather than a full reset so nested styles (code inside bold, bold inside a
|
|
27
|
+
* heading) survive. When `color` is false every helper is the identity.
|
|
28
|
+
*/
|
|
29
|
+
export declare function terminalStyles(color: boolean): TerminalStyles;
|
|
30
|
+
/** Remove ANSI/OSC control sequences from untrusted content. */
|
|
31
|
+
export declare function stripAnsi(text: string): string;
|
|
32
|
+
/**
|
|
33
|
+
* Render an agent markdown answer for direct terminal display.
|
|
34
|
+
*
|
|
35
|
+
* - Headers lose their `#` sigils; h1/h2 render bold+underlined, h3+ bold,
|
|
36
|
+
* with a blank line before.
|
|
37
|
+
* - `**bold**`, `*italic*`/`_italic_`, and backtick `code` spans lose their
|
|
38
|
+
* sigils and gain ANSI styling (bold / italic / cyan) when `color` is on.
|
|
39
|
+
* - List items are re-indented to consistent 2-space-per-level steps based on
|
|
40
|
+
* their original RELATIVE nesting (odd indents round to the nearest level; a
|
|
41
|
+
* top-level-looking item after deeper items stays top-level). Bullets render
|
|
42
|
+
* `•` at level 0 and `-` deeper; ordered items keep their numbers.
|
|
43
|
+
* - `|` table rows and fenced code blocks pass through untouched (only ANSI
|
|
44
|
+
* control sequences are stripped, since that content is backend-controlled).
|
|
45
|
+
* - Runs of 3+ blank lines collapse to a single blank line.
|
|
46
|
+
* - Prose is never reflowed or wrapped.
|
|
47
|
+
*/
|
|
48
|
+
export declare function renderMarkdownForTerminal(markdown: string, opts: MarkdownTerminalOptions): string;
|
|
49
|
+
//# sourceMappingURL=markdown-terminal.d.ts.map
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// src/markdown-terminal.ts
|
|
2
|
+
//
|
|
3
|
+
// Pure markdown -> terminal renderer for human-mode output (`moda ask`).
|
|
4
|
+
// The cloud agent answers in markdown; printing it raw leaves literal `##`
|
|
5
|
+
// header sigils, `**bold**` asterisks, and the model's erratic list
|
|
6
|
+
// indentation on screen. This module strips the sigils, applies ANSI styling
|
|
7
|
+
// when the terminal supports it, and normalizes list nesting to consistent
|
|
8
|
+
// 2-space steps — WITHOUT reflowing prose (terminals wrap themselves) and
|
|
9
|
+
// WITHOUT touching `|` tables or fenced code blocks.
|
|
10
|
+
//
|
|
11
|
+
// No dependencies: raw ANSI escape constants only (the CLI has no color lib).
|
|
12
|
+
/**
|
|
13
|
+
* The CLI's color convention (mirrors `src/tui.ts` / `src/analyst-tui.ts`):
|
|
14
|
+
* color only for a human at a TTY, honoring `NO_COLOR` (present with ANY value,
|
|
15
|
+
* including the empty string, disables — per the no-color.org spec) and
|
|
16
|
+
* `TERM=dumb`.
|
|
17
|
+
*/
|
|
18
|
+
export function shouldColorTerminalOutput(opts = {}) {
|
|
19
|
+
const env = opts.env ?? process.env;
|
|
20
|
+
const isTTY = opts.isTTY ?? process.stdout.isTTY === true;
|
|
21
|
+
if (!isTTY)
|
|
22
|
+
return false;
|
|
23
|
+
if (env.NO_COLOR !== undefined)
|
|
24
|
+
return false;
|
|
25
|
+
if (env.TERM === 'dumb')
|
|
26
|
+
return false;
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* ANSI style constructors. Closers restore the specific attribute (22/23/39)
|
|
31
|
+
* rather than a full reset so nested styles (code inside bold, bold inside a
|
|
32
|
+
* heading) survive. When `color` is false every helper is the identity.
|
|
33
|
+
*/
|
|
34
|
+
export function terminalStyles(color) {
|
|
35
|
+
const paint = (open, close) => (text) => color ? `\u001b[${open}m${text}\u001b[${close}m` : text;
|
|
36
|
+
return {
|
|
37
|
+
bold: paint('1', '22'),
|
|
38
|
+
italic: paint('3', '23'),
|
|
39
|
+
dim: paint('2', '22'),
|
|
40
|
+
code: paint('36', '39'),
|
|
41
|
+
heading: paint('1;4', '22;24'),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const FENCE_RE = /^\s*(`{3,}|~{3,})/;
|
|
45
|
+
const FENCE_CLOSE_RE = /^\s*(`{3,}|~{3,})\s*$/;
|
|
46
|
+
const TABLE_ROW_RE = /^\s*\|.*\S/;
|
|
47
|
+
// Matches ANSI/OSC control sequences: CSI (ESC [ … final byte), OSC (ESC ] …
|
|
48
|
+
// BEL or ST), and other two-byte escapes. Backend-controlled content (table
|
|
49
|
+
// cells, code-fence bodies, streamed tool names/titles) is passed through
|
|
50
|
+
// verbatim, so any escape sequences an LLM or a compromised backend emits must
|
|
51
|
+
// be stripped before they reach the terminal — otherwise they could clear the
|
|
52
|
+
// screen, spoof UI, or trigger OSC side-effects in some emulators.
|
|
53
|
+
const ANSI_RE = /\u001b(?:\[[0-9;?]*[ -/]*[@-~]|\][^\u0007\u001b]*(?:\u0007|\u001b\\)|[@-Z\\-_])/g;
|
|
54
|
+
/** Remove ANSI/OSC control sequences from untrusted content. */
|
|
55
|
+
export function stripAnsi(text) {
|
|
56
|
+
return text.replace(ANSI_RE, '');
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* A fenced code block's opening marker: the fence character (`` ` `` or `~`)
|
|
60
|
+
* and its length. CommonMark closes a fence only with the same character and at
|
|
61
|
+
* least as many markers, so tracking these prevents a `~~~` line from closing a
|
|
62
|
+
* ` ``` ` block (and a 3-backtick line from closing a 4-backtick opener).
|
|
63
|
+
*/
|
|
64
|
+
function parseFenceOpen(line) {
|
|
65
|
+
const match = FENCE_RE.exec(line);
|
|
66
|
+
if (!match)
|
|
67
|
+
return null;
|
|
68
|
+
return { char: match[1][0], len: match[1].length };
|
|
69
|
+
}
|
|
70
|
+
/** Whether `line` closes an open fence per the CommonMark same-char/length rule. */
|
|
71
|
+
function isFenceClose(line, fence) {
|
|
72
|
+
const match = FENCE_CLOSE_RE.exec(line);
|
|
73
|
+
return match !== null && match[1][0] === fence.char && match[1].length >= fence.len;
|
|
74
|
+
}
|
|
75
|
+
const HEADER_RE = /^(#{1,6})\s+(.*?)\s*#*\s*$/;
|
|
76
|
+
const BULLET_RE = /^(\s*)([-*+])\s+(.*)$/;
|
|
77
|
+
const ORDERED_RE = /^(\s*)(\d+)[.)]\s+(.*)$/;
|
|
78
|
+
/**
|
|
79
|
+
* Render an agent markdown answer for direct terminal display.
|
|
80
|
+
*
|
|
81
|
+
* - Headers lose their `#` sigils; h1/h2 render bold+underlined, h3+ bold,
|
|
82
|
+
* with a blank line before.
|
|
83
|
+
* - `**bold**`, `*italic*`/`_italic_`, and backtick `code` spans lose their
|
|
84
|
+
* sigils and gain ANSI styling (bold / italic / cyan) when `color` is on.
|
|
85
|
+
* - List items are re-indented to consistent 2-space-per-level steps based on
|
|
86
|
+
* their original RELATIVE nesting (odd indents round to the nearest level; a
|
|
87
|
+
* top-level-looking item after deeper items stays top-level). Bullets render
|
|
88
|
+
* `•` at level 0 and `-` deeper; ordered items keep their numbers.
|
|
89
|
+
* - `|` table rows and fenced code blocks pass through untouched (only ANSI
|
|
90
|
+
* control sequences are stripped, since that content is backend-controlled).
|
|
91
|
+
* - Runs of 3+ blank lines collapse to a single blank line.
|
|
92
|
+
* - Prose is never reflowed or wrapped.
|
|
93
|
+
*/
|
|
94
|
+
export function renderMarkdownForTerminal(markdown, opts) {
|
|
95
|
+
const style = terminalStyles(opts.color);
|
|
96
|
+
const out = [];
|
|
97
|
+
// Original indent width backing each active list level; index == level.
|
|
98
|
+
const listStack = [];
|
|
99
|
+
// The open fence's char + length, or null outside a fenced code block.
|
|
100
|
+
let fence = null;
|
|
101
|
+
for (const raw of markdown.replace(/\r\n/g, '\n').split('\n')) {
|
|
102
|
+
if (fence) {
|
|
103
|
+
out.push(stripAnsi(raw));
|
|
104
|
+
if (isFenceClose(raw, fence))
|
|
105
|
+
fence = null;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const opened = parseFenceOpen(raw);
|
|
109
|
+
if (opened) {
|
|
110
|
+
fence = opened;
|
|
111
|
+
listStack.length = 0;
|
|
112
|
+
out.push(stripAnsi(raw));
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (TABLE_ROW_RE.test(raw)) {
|
|
116
|
+
// Tables depend on exact column spacing — pass through monospaced.
|
|
117
|
+
out.push(stripAnsi(raw));
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const line = raw.replace(/\s+$/, '');
|
|
121
|
+
if (line === '') {
|
|
122
|
+
out.push('');
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const header = HEADER_RE.exec(line);
|
|
126
|
+
if (header) {
|
|
127
|
+
listStack.length = 0;
|
|
128
|
+
// Strip inline sigils inside the header text (plain), then style the
|
|
129
|
+
// whole line so nested `**bold**` can't fight the heading attributes.
|
|
130
|
+
const text = renderInline(header[2], terminalStyles(false));
|
|
131
|
+
if (out.length > 0 && out[out.length - 1] !== '')
|
|
132
|
+
out.push('');
|
|
133
|
+
out.push(header[1].length <= 2 ? style.heading(text) : style.bold(text));
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const bullet = BULLET_RE.exec(line);
|
|
137
|
+
const ordered = bullet ? null : ORDERED_RE.exec(line);
|
|
138
|
+
if (bullet || ordered) {
|
|
139
|
+
const match = (bullet ?? ordered);
|
|
140
|
+
const level = resolveListLevel(match[1].length, listStack);
|
|
141
|
+
const indent = ' '.repeat(level);
|
|
142
|
+
const text = renderInline(match[3], style);
|
|
143
|
+
if (bullet) {
|
|
144
|
+
out.push(`${indent}${level === 0 ? '•' : '-'} ${text}`);
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
out.push(`${indent}${match[2]}. ${text}`);
|
|
148
|
+
}
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
// Unindented prose ends any open list; indented non-list lines are treated
|
|
152
|
+
// as continuations and keep the current nesting context.
|
|
153
|
+
if (!/^\s{2,}/.test(line))
|
|
154
|
+
listStack.length = 0;
|
|
155
|
+
out.push(renderInline(line, style));
|
|
156
|
+
}
|
|
157
|
+
return collapseBlankRuns(out).join('\n');
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Map an item's original indent width onto a consistent nesting level.
|
|
161
|
+
* `stack[level]` remembers the original indent that opened that level:
|
|
162
|
+
* meaningfully shallower indents pop back out, meaningfully deeper indents
|
|
163
|
+
* open a new level, and anything within +/-1 space sticks to the current
|
|
164
|
+
* level (rounding the model's odd 3/5/7-space indents to the nearest level).
|
|
165
|
+
*/
|
|
166
|
+
function resolveListLevel(indent, stack) {
|
|
167
|
+
while (stack.length > 0 && indent < stack[stack.length - 1] - 1)
|
|
168
|
+
stack.pop();
|
|
169
|
+
if (stack.length === 0 || indent > stack[stack.length - 1] + 1)
|
|
170
|
+
stack.push(indent);
|
|
171
|
+
return stack.length - 1;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Strip/style inline markdown sigils: `code` spans first (protected via
|
|
175
|
+
* placeholders so asterisks/underscores inside them are never re-parsed),
|
|
176
|
+
* then `**bold**`/`__bold__`, then word-boundary `*italic*`/`_italic_`
|
|
177
|
+
* (mid-word underscores as in `silent_empty_result` never match).
|
|
178
|
+
*/
|
|
179
|
+
function renderInline(text, style) {
|
|
180
|
+
const codeSpans = [];
|
|
181
|
+
// Strip any ANSI escapes already in the (backend-controlled) text BEFORE we
|
|
182
|
+
// add our own styling, so this only removes injected sequences — never the
|
|
183
|
+
// codes the style helpers emit below.
|
|
184
|
+
let result = stripAnsi(text).replace(/`([^`]+)`/g, (_, code) => {
|
|
185
|
+
codeSpans.push(code);
|
|
186
|
+
return `\u0000${codeSpans.length - 1}\u0000`;
|
|
187
|
+
});
|
|
188
|
+
result = result.replace(/\*\*([^\s*](?:[^*]*[^\s*])?)\*\*/g, (_, inner) => style.bold(inner));
|
|
189
|
+
result = result.replace(/__([^\s_](?:[^_]*[^\s_])?)__/g, (_, inner) => style.bold(inner));
|
|
190
|
+
result = result.replace(/(?<![\w*])\*([^\s*](?:[^*\n]*[^\s*])?)\*(?![\w*])/g, (_, inner) => style.italic(inner));
|
|
191
|
+
result = result.replace(/(?<![\w_])_([^\s_](?:[^_\n]*[^\s_])?)_(?![\w_])/g, (_, inner) => style.italic(inner));
|
|
192
|
+
return result.replace(/\u0000(\d+)\u0000/g, (_, index) => style.code(codeSpans[Number(index)]));
|
|
193
|
+
}
|
|
194
|
+
/** Collapse runs of 3+ blank lines down to a single blank line. */
|
|
195
|
+
function collapseBlankRuns(lines) {
|
|
196
|
+
const collapsed = [];
|
|
197
|
+
let blanks = 0;
|
|
198
|
+
for (const line of lines) {
|
|
199
|
+
if (line === '') {
|
|
200
|
+
blanks += 1;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (blanks > 0)
|
|
204
|
+
collapsed.push('', ...(blanks === 2 ? [''] : []));
|
|
205
|
+
blanks = 0;
|
|
206
|
+
collapsed.push(line);
|
|
207
|
+
}
|
|
208
|
+
if (blanks > 0)
|
|
209
|
+
collapsed.push('', ...(blanks === 2 ? [''] : []));
|
|
210
|
+
return collapsed;
|
|
211
|
+
}
|
|
212
|
+
//# sourceMappingURL=markdown-terminal.js.map
|
|
@@ -151,6 +151,8 @@ export declare function askModa(options: {
|
|
|
151
151
|
daysBack: number;
|
|
152
152
|
cwd: string;
|
|
153
153
|
profile?: string;
|
|
154
|
+
/** Invoked if the POST hits a network fault and the one retry is used. */
|
|
155
|
+
onNetworkRetry?: () => void;
|
|
154
156
|
}): Promise<ProductionAskResult>;
|
|
155
157
|
/**
|
|
156
158
|
* Whether `moda ask` should use the live SSE endpoint: only for a human
|
|
@@ -166,6 +168,8 @@ export declare function shouldStreamAsk(context: Pick<CommandContext, 'outputMod
|
|
|
166
168
|
* confidence — the buffered endpoint also returns HTTP 200 in that case).
|
|
167
169
|
*/
|
|
168
170
|
export declare function askResultFromStreamOutcome(outcome: AskStreamOutcome, question: string): ProductionAskResult;
|
|
169
|
-
export declare function renderAskHuman(result: ProductionAskResult
|
|
171
|
+
export declare function renderAskHuman(result: ProductionAskResult, options?: {
|
|
172
|
+
color?: boolean;
|
|
173
|
+
}): string;
|
|
170
174
|
export {};
|
|
171
175
|
//# sourceMappingURL=production-intelligence.d.ts.map
|
|
@@ -1,10 +1,11 @@
|
|
|
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 { streamAsk } from './ask-stream.js';
|
|
4
|
+
import { streamAsk, streamFallbackNotice } from './ask-stream.js';
|
|
5
5
|
import { resolveModaBaseUrl } from './config.js';
|
|
6
6
|
import { CliAuthError, CliInputError } from './errors.js';
|
|
7
7
|
import { EXIT_DEGRADED, EXIT_OK, safeWriteStderr } from './output.js';
|
|
8
|
+
import { renderMarkdownForTerminal, shouldColorTerminalOutput, stripAnsi, terminalStyles, } from './markdown-terminal.js';
|
|
8
9
|
import { asNumber, asRecord, asString } from './transforms.js';
|
|
9
10
|
import { INTELLIGENCE_SCHEMA_VERSION, } from './intelligence-schema.js';
|
|
10
11
|
const INVESTIGATION_SCHEMA_VERSION = 'production_investigation.v0.1';
|
|
@@ -182,7 +183,16 @@ export async function askModa(options) {
|
|
|
182
183
|
profile: options.profile,
|
|
183
184
|
harness: localHarnessSummary(options.cwd),
|
|
184
185
|
}),
|
|
185
|
-
}, options.profile ? { profile: options.profile } : {}, {
|
|
186
|
+
}, options.profile ? { profile: options.profile } : {}, {
|
|
187
|
+
timeoutMs: askTimeoutMs(),
|
|
188
|
+
retries: 0,
|
|
189
|
+
// A stream that just dropped can poison undici's pooled connection to
|
|
190
|
+
// this origin; without this the buffered fallback fails instantly at
|
|
191
|
+
// the transport level against a healthy backend. One retry, network
|
|
192
|
+
// faults only — HTTP errors and the run timeout still never retry.
|
|
193
|
+
retryOnceOnNetworkError: true,
|
|
194
|
+
onNetworkRetry: options.onNetworkRetry,
|
|
195
|
+
});
|
|
186
196
|
const cloud = normalizeCloudAsk(response, options.question);
|
|
187
197
|
if (cloud)
|
|
188
198
|
return cloud;
|
|
@@ -262,6 +272,11 @@ export function shouldStreamAsk(context, isTTY = process.stdout.isTTY === true)
|
|
|
262
272
|
*/
|
|
263
273
|
async function askModaForContext(context, question, daysBack) {
|
|
264
274
|
if (shouldStreamAsk(context)) {
|
|
275
|
+
// Progress lines go to stderr, so key color off the stderr TTY. Tool cards
|
|
276
|
+
// (`• …`), deliberation lines (`◦ …`), and liveness ticks (`… …`) are
|
|
277
|
+
// dimmed subtly; phase lines (`▸ …`) stay at full strength.
|
|
278
|
+
const progressStyle = terminalStyles(shouldColorTerminalOutput({ isTTY: process.stderr.isTTY === true }));
|
|
279
|
+
const dimmed = (line) => line.startsWith('•') || line.startsWith('◦') || line.startsWith('…');
|
|
265
280
|
try {
|
|
266
281
|
const outcome = await streamAsk({
|
|
267
282
|
question,
|
|
@@ -269,7 +284,7 @@ async function askModaForContext(context, question, daysBack) {
|
|
|
269
284
|
profile: context.profile,
|
|
270
285
|
harness: localHarnessSummary(context.cwd),
|
|
271
286
|
timeoutMs: askTimeoutMs(),
|
|
272
|
-
onProgress: (line) => safeWriteStderr(`${line}\n`),
|
|
287
|
+
onProgress: (line) => safeWriteStderr(`${dimmed(line) ? progressStyle.dim(line) : line}\n`),
|
|
273
288
|
});
|
|
274
289
|
return askResultFromStreamOutcome(outcome, question);
|
|
275
290
|
}
|
|
@@ -277,12 +292,26 @@ async function askModaForContext(context, question, daysBack) {
|
|
|
277
292
|
// Auth problems must keep their structured exit-4 handling.
|
|
278
293
|
if (error instanceof CliAuthError)
|
|
279
294
|
throw error;
|
|
280
|
-
|
|
295
|
+
const notice = streamFallbackNotice(error);
|
|
296
|
+
if (notice) {
|
|
297
|
+
// The stream died AFTER substantial visible progress: the buffered
|
|
298
|
+
// re-run roughly doubles the wait, so say so instead of going silent.
|
|
299
|
+
safeWriteStderr(`[moda] ${notice}\n`);
|
|
300
|
+
}
|
|
301
|
+
else if (context.verbose) {
|
|
281
302
|
safeWriteStderr(`[moda] live progress unavailable (${error instanceof Error ? error.message : String(error)}); using buffered ask.\n`);
|
|
282
303
|
}
|
|
283
304
|
}
|
|
284
305
|
}
|
|
285
|
-
return askModa({
|
|
306
|
+
return askModa({
|
|
307
|
+
question,
|
|
308
|
+
daysBack,
|
|
309
|
+
cwd: context.cwd,
|
|
310
|
+
profile: context.profile,
|
|
311
|
+
onNetworkRetry: context.verbose
|
|
312
|
+
? () => safeWriteStderr('[moda] network hiccup reaching Moda Cloud; retrying the buffered ask once…\n')
|
|
313
|
+
: undefined,
|
|
314
|
+
});
|
|
286
315
|
}
|
|
287
316
|
/**
|
|
288
317
|
* Project a completed stream into the same `ProductionAskResult` the buffered
|
|
@@ -1023,22 +1052,26 @@ function renderInvestigationHuman(investigation) {
|
|
|
1023
1052
|
}
|
|
1024
1053
|
return lines.join('\n');
|
|
1025
1054
|
}
|
|
1026
|
-
export function renderAskHuman(result) {
|
|
1055
|
+
export function renderAskHuman(result, options = {}) {
|
|
1056
|
+
// Same convention as the rest of the CLI: color only for a human at a TTY,
|
|
1057
|
+
// honoring NO_COLOR and TERM=dumb. All information is identical either way.
|
|
1058
|
+
const color = options.color ?? shouldColorTerminalOutput();
|
|
1059
|
+
const style = terminalStyles(color);
|
|
1027
1060
|
const lines = [];
|
|
1028
|
-
lines.push('Moda ask');
|
|
1029
|
-
lines.push(
|
|
1061
|
+
lines.push(style.heading('Moda ask'));
|
|
1062
|
+
lines.push(`${style.dim('Question:')} ${result.question}`);
|
|
1030
1063
|
if (result.degraded) {
|
|
1031
1064
|
lines.push('');
|
|
1032
1065
|
lines.push('[degraded] Moda Cloud was unavailable — this answer is synthesized from local Data API evidence (lower trust).');
|
|
1033
1066
|
}
|
|
1034
1067
|
lines.push('');
|
|
1035
|
-
lines.push(result.answer);
|
|
1068
|
+
lines.push(renderMarkdownForTerminal(result.answer, { color }));
|
|
1036
1069
|
lines.push('');
|
|
1037
|
-
lines.push(`Confidence: ${result.confidence}`);
|
|
1070
|
+
lines.push(`Confidence: ${style.bold(result.confidence)}`);
|
|
1038
1071
|
lines.push(`Source: ${result.source}${result.degraded ? ' (degraded)' : ''}`);
|
|
1039
1072
|
if (result.evidenceRefs.length > 0) {
|
|
1040
1073
|
lines.push('');
|
|
1041
|
-
lines.push('Evidence');
|
|
1074
|
+
lines.push(style.bold('Evidence'));
|
|
1042
1075
|
// Subtle grounding marker: flag model-inferred evidence so a human reader
|
|
1043
1076
|
// can weight it. Ids/anchors stay hidden in the human projection.
|
|
1044
1077
|
const grounding = new Map(result.intelligence.evidence_refs.map((ref) => [ref.id, ref.grounding]));
|
|
@@ -1047,8 +1080,11 @@ export function renderAskHuman(result) {
|
|
|
1047
1080
|
const marker = grounding.get(ref.id) === 'inferred' ? ' (inferred)' : '';
|
|
1048
1081
|
// Backend hrefs are root-relative dashboard paths; print the absolute
|
|
1049
1082
|
// URL so terminals auto-linkify it and the reader can click through.
|
|
1050
|
-
|
|
1051
|
-
|
|
1083
|
+
// Strip ANSI from all backend-controlled fields before printing.
|
|
1084
|
+
const label = stripAnsi(ref.label);
|
|
1085
|
+
const path = ref.path ? ` (${stripAnsi(ref.path)})` : '';
|
|
1086
|
+
const link = ref.href ? `\n ${style.dim(`${dashboardBase}${stripAnsi(ref.href)}`)}` : '';
|
|
1087
|
+
lines.push(` - ${label}${path}${marker}${link}`);
|
|
1052
1088
|
}
|
|
1053
1089
|
}
|
|
1054
1090
|
const unverifiedClaims = result.intelligence.answer_claims.filter((claim) => claim.status !== 'grounded').length;
|
|
@@ -1058,15 +1094,15 @@ export function renderAskHuman(result) {
|
|
|
1058
1094
|
}
|
|
1059
1095
|
if (result.nextCommands.length > 0) {
|
|
1060
1096
|
lines.push('');
|
|
1061
|
-
lines.push('Next commands');
|
|
1097
|
+
lines.push(style.bold('Next commands'));
|
|
1062
1098
|
for (const command of result.nextCommands.slice(0, 5))
|
|
1063
|
-
lines.push(` ${command}`);
|
|
1099
|
+
lines.push(` ${stripAnsi(command)}`);
|
|
1064
1100
|
}
|
|
1065
1101
|
if (result.warnings.length > 0) {
|
|
1066
1102
|
lines.push('');
|
|
1067
|
-
lines.push('Warnings');
|
|
1103
|
+
lines.push(style.bold('Warnings'));
|
|
1068
1104
|
for (const warning of result.warnings.slice(0, 3))
|
|
1069
|
-
lines.push(` - ${warning}`);
|
|
1105
|
+
lines.push(` - ${stripAnsi(warning)}`);
|
|
1070
1106
|
}
|
|
1071
1107
|
return lines.join('\n');
|
|
1072
1108
|
}
|
package/package.json
CHANGED