unbound-cli 1.11.1 → 1.12.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/package.json +1 -1
- package/src/api.js +7 -3
- package/src/chartRender.js +16 -0
- package/src/commands/chat.js +183 -153
- package/test/chart-render.test.js +24 -0
- package/test/chat-internals.test.js +114 -0
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -57,11 +57,15 @@ function request(method, path, { body, query, apiKey, baseUrl, timeoutMs } = {})
|
|
|
57
57
|
'Accept': 'application/json',
|
|
58
58
|
};
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
const payload = body ? JSON.stringify(body) : null;
|
|
61
|
+
|
|
62
|
+
if (payload !== null) {
|
|
61
63
|
headers['Content-Type'] = 'application/json';
|
|
64
|
+
// Without this Node falls back to Transfer-Encoding: chunked, and Django's
|
|
65
|
+
// runserver (wsgiref) reads CONTENT_LENGTH only — so a chunked body arrives
|
|
66
|
+
// empty and every POST looks like it was sent with no fields at all.
|
|
67
|
+
headers['Content-Length'] = Buffer.byteLength(payload);
|
|
62
68
|
}
|
|
63
|
-
|
|
64
|
-
const payload = body ? JSON.stringify(body) : null;
|
|
65
69
|
const transport = url.protocol === 'https:' ? https : http;
|
|
66
70
|
|
|
67
71
|
return new Promise((resolve, reject) => {
|
package/src/chartRender.js
CHANGED
|
@@ -82,6 +82,22 @@ function reconstructRows(chart_option, chart_meta) {
|
|
|
82
82
|
return sanitizeForTerminal(v);
|
|
83
83
|
};
|
|
84
84
|
|
|
85
|
+
// 0) table charts — the builder emits {columns, rows} under `table` rather than
|
|
86
|
+
// an axis/series pair, so this shape has no overlap with the ones below. Without
|
|
87
|
+
// it every table (any result with too many series or dimensions to plot) decodes
|
|
88
|
+
// to nothing and renders as "(no rows to render)".
|
|
89
|
+
const table = chart_option.table;
|
|
90
|
+
if (table && Array.isArray(table.columns) && Array.isArray(table.rows)) {
|
|
91
|
+
const columns = table.columns.map((c) => sanitizeForTerminal(String(c)));
|
|
92
|
+
const rows = table.rows.slice(0, MAX_RECONSTRUCT_ROWS).map((r) => (
|
|
93
|
+
Object.fromEntries(columns.map((k, i) => {
|
|
94
|
+
const raw = Array.isArray(r) ? r[i] : r?.[table.columns[i]];
|
|
95
|
+
return [k, sanitizeLabel(raw)];
|
|
96
|
+
}))
|
|
97
|
+
));
|
|
98
|
+
return { columns, rows };
|
|
99
|
+
}
|
|
100
|
+
|
|
85
101
|
// 1) dataset.source — preferred when present
|
|
86
102
|
const source = chart_option.dataset?.source;
|
|
87
103
|
if (Array.isArray(source) && source.length > 0) {
|
package/src/commands/chat.js
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `unbound chat` — interactive session or one-shot wrapper around
|
|
3
|
-
* POST /api/v1/query_builder/
|
|
4
|
-
* only; nothing is written to disk. In one-shot mode (-m), `--json`
|
|
5
|
-
* the response to stdout and `-o <path>` writes it to a file.
|
|
3
|
+
* POST /api/v1/query_builder/agent/converse/. Conversation history lives in
|
|
4
|
+
* memory only; nothing is written to disk. In one-shot mode (-m), `--json`
|
|
5
|
+
* writes the response to stdout and `-o <path>` writes it to a file.
|
|
6
|
+
*
|
|
7
|
+
* A turn is an ordered transcript: the agent interleaves prose and charts, and
|
|
8
|
+
* both the terminal rendering and the JSON preserve that order. The JSON carries
|
|
9
|
+
* decoded rows rather than the chart option the wire format uses, so whatever
|
|
10
|
+
* consumes it — usually a coding agent — gets tabular data it can work with.
|
|
6
11
|
*/
|
|
7
12
|
|
|
8
13
|
const fs = require('fs');
|
|
@@ -16,9 +21,10 @@ const { ensureLoggedIn } = require('../auth');
|
|
|
16
21
|
const chart = require('../chartRender');
|
|
17
22
|
const { sanitizeForTerminal } = chart;
|
|
18
23
|
|
|
19
|
-
const ENDPOINT = '/api/v1/query_builder/
|
|
24
|
+
const ENDPOINT = '/api/v1/query_builder/agent/converse/';
|
|
25
|
+
// Above the server's own 300s cap, so a turn is never abandoned while it works.
|
|
26
|
+
const REQUEST_TIMEOUT_MS = 300000;
|
|
20
27
|
const HISTORY_CAP = 20;
|
|
21
|
-
const RECENT_RESULTS_CAP = 5;
|
|
22
28
|
const MAX_MESSAGE_LEN = 4000;
|
|
23
29
|
const MAX_TURN_CONTENT_LEN = 8000;
|
|
24
30
|
const ALLOWED_ROLES = new Set(['user', 'assistant']);
|
|
@@ -79,24 +85,64 @@ function safeWriteFile(target, data) {
|
|
|
79
85
|
return resolved;
|
|
80
86
|
}
|
|
81
87
|
|
|
82
|
-
function buildBody(message, history,
|
|
83
|
-
|
|
88
|
+
function buildBody(message, history, conversationId) {
|
|
89
|
+
const body = {
|
|
84
90
|
message,
|
|
85
91
|
conversation_history: history.slice(-HISTORY_CAP),
|
|
86
|
-
recent_results: recent.slice(-RECENT_RESULTS_CAP),
|
|
87
92
|
};
|
|
93
|
+
if (conversationId) body.conversation_id = conversationId;
|
|
94
|
+
return body;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// The model gets the words, not the tables. Same flattening the web client uses.
|
|
98
|
+
function flattenBlocks(blocks) {
|
|
99
|
+
return (blocks || [])
|
|
100
|
+
.map((b) => (b.type === 'text' ? b.text : `[chart: ${b.title}]`))
|
|
101
|
+
.filter(Boolean)
|
|
102
|
+
.join('\n\n');
|
|
88
103
|
}
|
|
89
104
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
105
|
+
/**
|
|
106
|
+
* The transcript, in order, with charts decoded to rows.
|
|
107
|
+
*
|
|
108
|
+
* This is the CLI's JSON contract. The wire format carries an ECharts option
|
|
109
|
+
* because the browser renders one; nothing downstream of `--json` wants that, so
|
|
110
|
+
* charts are reduced to view_spec + columns + rows.
|
|
111
|
+
*/
|
|
112
|
+
function toJsonPayload(resp) {
|
|
113
|
+
const blocks = Array.isArray(resp?.blocks) ? resp.blocks : [];
|
|
114
|
+
return {
|
|
115
|
+
success: resp?.success !== false,
|
|
116
|
+
// Without it a --json consumer gets exit 1 and no reason.
|
|
117
|
+
error: resp?.error ?? null,
|
|
118
|
+
conversation_id: resp?.conversation_id ?? null,
|
|
119
|
+
log_id: resp?.log_id ?? null,
|
|
120
|
+
// Ran out of budget mid-answer; the blocks already here are real.
|
|
121
|
+
truncated: resp?.truncated === true,
|
|
122
|
+
blocks: blocks.map((b) => {
|
|
123
|
+
if (b.type === 'text') return { type: 'text', text: b.text };
|
|
124
|
+
const result = b.result || {};
|
|
125
|
+
let columns = [];
|
|
126
|
+
let rows = [];
|
|
127
|
+
let decoded = true;
|
|
128
|
+
try {
|
|
129
|
+
({ columns, rows } = chart.reconstructRows(result.chart_option, result.chart_meta));
|
|
130
|
+
} catch {
|
|
131
|
+
decoded = false;
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
type: 'chart',
|
|
135
|
+
title: b.title,
|
|
136
|
+
view_spec: result.view_spec ?? null,
|
|
137
|
+
chart_type: result.chart_meta?.chart_type ?? null,
|
|
138
|
+
row_count: result.row_count ?? rows.length,
|
|
139
|
+
columns,
|
|
140
|
+
rows,
|
|
141
|
+
// Empty rows because it would not decode, not because the answer was empty.
|
|
142
|
+
...(decoded ? {} : { decode_failed: true }),
|
|
143
|
+
};
|
|
144
|
+
}),
|
|
145
|
+
};
|
|
100
146
|
}
|
|
101
147
|
|
|
102
148
|
function pushHistory(history, turn) {
|
|
@@ -126,51 +172,31 @@ function friendlyChatError(err) {
|
|
|
126
172
|
return sanitizeForTerminal(err?.message || 'Request failed.');
|
|
127
173
|
}
|
|
128
174
|
|
|
129
|
-
|
|
130
|
-
|
|
175
|
+
/** Print one chart: its title, a one-line summary, then a body or a table. */
|
|
176
|
+
function renderChartBlock(block) {
|
|
177
|
+
const result = block.result || {};
|
|
131
178
|
console.log('');
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const label = sanitizeForTerminal(s.label || `Suggestion ${i + 1}`);
|
|
135
|
-
const query = sanitizeForTerminal(s.query || '');
|
|
136
|
-
const desc = sanitizeForTerminal(s.description || '');
|
|
137
|
-
console.log(` ${i + 1}. ${bold(label)}`);
|
|
138
|
-
if (query) console.log(` ${query}`);
|
|
139
|
-
if (desc) console.log(` ${dim(desc)}`);
|
|
140
|
-
});
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* Print the assistant message + (if a chart) a one-line summary and a chart
|
|
145
|
-
* body. Suggestions are NOT printed here — caller decides (REPL picker vs
|
|
146
|
-
* one-shot numbered list).
|
|
147
|
-
*/
|
|
148
|
-
function renderResponse(resp) {
|
|
149
|
-
const intent = resp?.intent;
|
|
150
|
-
const message = sanitizeForTerminal(resp?.message || '');
|
|
179
|
+
console.log(bold(' ' + sanitizeForTerminal(block.title || 'Result')));
|
|
180
|
+
console.log(dim(' ' + sanitizeForTerminal(chart.summarizeChart(result.chart_meta, result.row_count))));
|
|
151
181
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
182
|
+
let columns = [];
|
|
183
|
+
let rows = [];
|
|
184
|
+
let decoded = true;
|
|
185
|
+
try {
|
|
186
|
+
({ columns, rows } = chart.reconstructRows(result.chart_option, result.chart_meta));
|
|
187
|
+
} catch {
|
|
188
|
+
decoded = false;
|
|
155
189
|
}
|
|
156
|
-
|
|
157
|
-
if (
|
|
158
|
-
|
|
190
|
+
// Not the same as a chart with no rows, and must not read like one.
|
|
191
|
+
if (!decoded) {
|
|
192
|
+
console.log(dim(' (could not read this chart — --json still carries its spec)'));
|
|
159
193
|
return;
|
|
160
194
|
}
|
|
161
|
-
|
|
162
|
-
if (message) console.log(cyan('\u25cf ') + message);
|
|
163
|
-
const result = resp.result;
|
|
164
|
-
const summary = sanitizeForTerminal(chart.summarizeChart(result.chart_meta, result.row_count));
|
|
165
|
-
console.log(dim(' ' + summary));
|
|
166
|
-
|
|
167
|
-
const { columns, rows } = chart.reconstructRows(result.chart_option, result.chart_meta);
|
|
168
195
|
if (!rows.length) {
|
|
169
196
|
console.log(dim(' (no rows to render)'));
|
|
170
197
|
return;
|
|
171
198
|
}
|
|
172
199
|
|
|
173
|
-
console.log('');
|
|
174
200
|
const cap = chart.MAX_RENDER_ROWS;
|
|
175
201
|
const renderRows = rows.slice(0, cap);
|
|
176
202
|
const chartType = result.chart_meta?.chart_type;
|
|
@@ -201,19 +227,50 @@ function renderResponse(resp) {
|
|
|
201
227
|
}
|
|
202
228
|
}
|
|
203
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Print the turn in the order the agent produced it — prose where it wrote
|
|
232
|
+
* prose, a chart where it ran one. A turn can hold several of each.
|
|
233
|
+
*/
|
|
234
|
+
function renderResponse(resp) {
|
|
235
|
+
if (resp?.success === false) {
|
|
236
|
+
output.error(sanitizeForTerminal(resp?.error || 'The request could not be processed.'));
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const blocks = Array.isArray(resp?.blocks) ? resp.blocks : [];
|
|
241
|
+
if (!blocks.length) {
|
|
242
|
+
output.warn('The assistant returned nothing. Try rephrasing the question.');
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
for (const block of blocks) {
|
|
247
|
+
if (block?.type === 'text') {
|
|
248
|
+
if (block.text) console.log(cyan('\u25cf ') + sanitizeForTerminal(block.text));
|
|
249
|
+
} else if (block?.type === 'chart') {
|
|
250
|
+
renderChartBlock(block);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (resp?.truncated) {
|
|
255
|
+
console.log('');
|
|
256
|
+
output.warn('That is as far as it got in one turn. Ask for the rest to continue.');
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
204
260
|
async function runOneShot({ message, json, outputPath }) {
|
|
205
|
-
const body = buildBody(message, [],
|
|
261
|
+
const body = buildBody(message, [], null);
|
|
206
262
|
|
|
207
263
|
if (json || outputPath) {
|
|
208
264
|
let resp;
|
|
209
265
|
try {
|
|
210
|
-
resp = await api.post(ENDPOINT, { body });
|
|
266
|
+
resp = await api.post(ENDPOINT, { body, timeoutMs: REQUEST_TIMEOUT_MS });
|
|
211
267
|
} catch (err) {
|
|
212
268
|
output.error(friendlyChatError(err));
|
|
213
269
|
process.exitCode = 1;
|
|
214
270
|
return;
|
|
215
271
|
}
|
|
216
|
-
const
|
|
272
|
+
const payload = toJsonPayload(resp);
|
|
273
|
+
const serialized = JSON.stringify(payload, null, 2) + '\n';
|
|
217
274
|
// Write to stdout first (when requested) so a downstream `-o` write failure
|
|
218
275
|
// doesn't eat the response when both flags are combined.
|
|
219
276
|
if (json) {
|
|
@@ -229,14 +286,14 @@ async function runOneShot({ message, json, outputPath }) {
|
|
|
229
286
|
return;
|
|
230
287
|
}
|
|
231
288
|
}
|
|
232
|
-
if (
|
|
289
|
+
if (!payload.success || !payload.blocks.length) process.exitCode = 1;
|
|
233
290
|
return;
|
|
234
291
|
}
|
|
235
292
|
|
|
236
293
|
const spin = output.spinner('Thinking...');
|
|
237
294
|
let resp;
|
|
238
295
|
try {
|
|
239
|
-
resp = await api.post(ENDPOINT, { body });
|
|
296
|
+
resp = await api.post(ENDPOINT, { body, timeoutMs: REQUEST_TIMEOUT_MS });
|
|
240
297
|
spin.stop();
|
|
241
298
|
} catch (err) {
|
|
242
299
|
spin.fail(friendlyChatError(err));
|
|
@@ -245,8 +302,7 @@ async function runOneShot({ message, json, outputPath }) {
|
|
|
245
302
|
}
|
|
246
303
|
|
|
247
304
|
renderResponse(resp);
|
|
248
|
-
|
|
249
|
-
if (resp?.intent === 'invalid') process.exitCode = 1;
|
|
305
|
+
if (resp?.success === false || !(resp?.blocks || []).length) process.exitCode = 1;
|
|
250
306
|
}
|
|
251
307
|
|
|
252
308
|
function printBanner() {
|
|
@@ -281,19 +337,19 @@ function handleSlash(line, state) {
|
|
|
281
337
|
return 'continue';
|
|
282
338
|
case '/clear':
|
|
283
339
|
state.history.length = 0;
|
|
284
|
-
state.
|
|
285
|
-
state.
|
|
340
|
+
state.conversationId = null;
|
|
341
|
+
state.lastPayload = null;
|
|
286
342
|
output.success('Conversation cleared.');
|
|
287
343
|
return 'continue';
|
|
288
344
|
case '/json':
|
|
289
|
-
if (state.
|
|
345
|
+
if (state.lastPayload) console.log(JSON.stringify(state.lastPayload, null, 2));
|
|
290
346
|
else console.log(dim('(no result yet)'));
|
|
291
347
|
return 'continue';
|
|
292
348
|
case '/export': {
|
|
293
|
-
if (!state.
|
|
349
|
+
if (!state.lastPayload) { output.error('No result to export yet. Ask a question first.'); return 'continue'; }
|
|
294
350
|
if (!argStr) { output.error('Usage: /export <path>'); return 'continue'; }
|
|
295
351
|
try {
|
|
296
|
-
const resolved = safeWriteFile(argStr, JSON.stringify(state.
|
|
352
|
+
const resolved = safeWriteFile(argStr, JSON.stringify(state.lastPayload, null, 2) + '\n');
|
|
297
353
|
output.success(`Wrote ${resolved}`);
|
|
298
354
|
} catch (err) {
|
|
299
355
|
output.error(sanitizeForTerminal(err.message));
|
|
@@ -306,8 +362,8 @@ function handleSlash(line, state) {
|
|
|
306
362
|
}
|
|
307
363
|
}
|
|
308
364
|
|
|
309
|
-
async function runRepl(
|
|
310
|
-
const state = { history: [],
|
|
365
|
+
async function runRepl() {
|
|
366
|
+
const state = { history: [], conversationId: null, lastPayload: null };
|
|
311
367
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
312
368
|
let closed = false;
|
|
313
369
|
const setClosed = () => { closed = true; };
|
|
@@ -315,20 +371,13 @@ async function runRepl({ noSuggestions }) {
|
|
|
315
371
|
rl.on('close', setClosed);
|
|
316
372
|
|
|
317
373
|
printBanner();
|
|
318
|
-
let pending = null;
|
|
319
374
|
|
|
320
375
|
while (!closed) {
|
|
321
376
|
let msg;
|
|
322
|
-
|
|
323
|
-
msg =
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
} else {
|
|
327
|
-
try {
|
|
328
|
-
msg = await rl.question(`${bold('chat>')} `);
|
|
329
|
-
} catch {
|
|
330
|
-
break;
|
|
331
|
-
}
|
|
377
|
+
try {
|
|
378
|
+
msg = await rl.question(`${bold('chat>')} `);
|
|
379
|
+
} catch {
|
|
380
|
+
break;
|
|
332
381
|
}
|
|
333
382
|
if (closed) break;
|
|
334
383
|
msg = (msg || '').trim();
|
|
@@ -347,12 +396,12 @@ async function runRepl({ noSuggestions }) {
|
|
|
347
396
|
// Build the request body BEFORE pushing the current turn into history —
|
|
348
397
|
// otherwise `message` and the last entry of `conversation_history` would
|
|
349
398
|
// both contain the same user text, making the backend process it twice.
|
|
350
|
-
const body = buildBody(msg, state.history, state.
|
|
399
|
+
const body = buildBody(msg, state.history, state.conversationId);
|
|
351
400
|
|
|
352
401
|
const spin = output.spinner('Thinking...');
|
|
353
402
|
let resp;
|
|
354
403
|
try {
|
|
355
|
-
resp = await api.post(ENDPOINT, { body });
|
|
404
|
+
resp = await api.post(ENDPOINT, { body, timeoutMs: REQUEST_TIMEOUT_MS });
|
|
356
405
|
spin.stop();
|
|
357
406
|
} catch (err) {
|
|
358
407
|
spin.fail(friendlyChatError(err));
|
|
@@ -360,44 +409,32 @@ async function runRepl({ noSuggestions }) {
|
|
|
360
409
|
continue;
|
|
361
410
|
}
|
|
362
411
|
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
|
|
412
|
+
// No answer means no turn: recording the question would send the assistant
|
|
413
|
+
// something it never replied to. The id stays so the server's rows group.
|
|
414
|
+
if (resp?.conversation_id) state.conversationId = resp.conversation_id;
|
|
415
|
+
if (resp?.success === false || !(resp?.blocks || []).length) {
|
|
416
|
+
renderResponse(resp);
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
366
419
|
|
|
367
|
-
const assistantTurn = normalizeAssistantTurn(
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
420
|
+
const assistantTurn = normalizeAssistantTurn({
|
|
421
|
+
role: 'assistant',
|
|
422
|
+
content: flattenBlocks(resp?.blocks),
|
|
423
|
+
timestamp: nowIso(),
|
|
424
|
+
});
|
|
425
|
+
// The pair goes in together or not at all. A reply that flattens to nothing
|
|
426
|
+
// would leave the question in history with no answer beside it, and the next
|
|
427
|
+
// request would send the model two user turns in a row.
|
|
428
|
+
if (!assistantTurn?.content) {
|
|
429
|
+
renderResponse(resp);
|
|
430
|
+
continue;
|
|
372
431
|
}
|
|
432
|
+
pushHistory(state.history, { role: 'user', content: msg, timestamp: nowIso() });
|
|
433
|
+
pushHistory(state.history, assistantTurn);
|
|
434
|
+
state.lastPayload = toJsonPayload(resp);
|
|
373
435
|
|
|
374
436
|
renderResponse(resp);
|
|
375
437
|
|
|
376
|
-
const suggestions = Array.isArray(resp?.suggestions) ? resp.suggestions : [];
|
|
377
|
-
if (suggestions.length && !noSuggestions && process.stdin.isTTY && process.stdout.isTTY) {
|
|
378
|
-
const pickOptions = [
|
|
379
|
-
...suggestions.map((s, i) => ({
|
|
380
|
-
label: sanitizeForTerminal(s.label || `Suggestion ${i + 1}`)
|
|
381
|
-
+ (s.description ? ' ' + dim('— ' + sanitizeForTerminal(s.description)) : ''),
|
|
382
|
-
value: sanitizeForTerminal(s.query || ''),
|
|
383
|
-
})).filter((o) => o.value),
|
|
384
|
-
{ label: dim('(type your own)'), value: null },
|
|
385
|
-
{ label: dim('(exit)'), value: '__exit__' },
|
|
386
|
-
];
|
|
387
|
-
let pick;
|
|
388
|
-
try {
|
|
389
|
-
rl.pause();
|
|
390
|
-
pick = await output.select('Follow up?', pickOptions);
|
|
391
|
-
} catch {
|
|
392
|
-
pick = null;
|
|
393
|
-
} finally {
|
|
394
|
-
rl.resume();
|
|
395
|
-
}
|
|
396
|
-
if (pick === '__exit__') break;
|
|
397
|
-
if (pick) pending = pick;
|
|
398
|
-
} else if (suggestions.length && (!process.stdin.isTTY || !process.stdout.isTTY)) {
|
|
399
|
-
renderSuggestionsForHuman(suggestions);
|
|
400
|
-
}
|
|
401
438
|
}
|
|
402
439
|
|
|
403
440
|
rl.close();
|
|
@@ -411,64 +448,54 @@ function register(program) {
|
|
|
411
448
|
.option('-m, --message <text>', 'Ask a single question and exit (no interactive session).')
|
|
412
449
|
.option('--json', 'Write the raw JSON response to stdout. Use with -m.')
|
|
413
450
|
.option('-o, --output <path>', 'Write the raw JSON response to a file. Refuses to overwrite. Use with -m.')
|
|
414
|
-
.option('--no-suggestions', 'Skip the follow-up picker after each turn (interactive session only).')
|
|
415
451
|
.addHelpText('after', `
|
|
416
452
|
EXAMPLES
|
|
417
453
|
Start an interactive session:
|
|
418
454
|
$ unbound chat
|
|
419
455
|
|
|
420
|
-
Ask a single question and render
|
|
456
|
+
Ask a single question and render it in the terminal:
|
|
421
457
|
$ unbound chat -m "show cost by provider for the last 30 days"
|
|
422
458
|
|
|
423
|
-
Write the
|
|
459
|
+
Write the JSON transcript to stdout (pipe to jq, a script, or a coding agent):
|
|
424
460
|
$ unbound chat -m "cost by provider last 30 days" --json
|
|
425
|
-
$ unbound chat -m "cost by provider last 30 days" --json | jq '.
|
|
426
|
-
$ unbound chat -m "cost by provider last 30 days" --json | jq '.
|
|
461
|
+
$ unbound chat -m "cost by provider last 30 days" --json | jq '.blocks[].text'
|
|
462
|
+
$ unbound chat -m "cost by provider last 30 days" --json | jq '.blocks[] | select(.type=="chart") | .rows'
|
|
427
463
|
|
|
428
|
-
Write
|
|
464
|
+
Write it to a file:
|
|
429
465
|
$ unbound chat -m "cost by provider last 30 days" -o cost.json
|
|
430
466
|
|
|
431
467
|
Combine --json and -o to pipe and archive at the same time:
|
|
432
|
-
$ unbound chat -m "cost by provider last 30 days" --json -o cost.json | jq '.
|
|
468
|
+
$ unbound chat -m "cost by provider last 30 days" --json -o cost.json | jq '.blocks | length'
|
|
433
469
|
|
|
434
470
|
OUTPUT FORMAT (--json and -o)
|
|
435
|
-
{
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
DATA EXTRACTION (rows are embedded in chart_option; no separate rows field)
|
|
457
|
-
bar, line chart_option.xAxis.data (category labels, ordered)
|
|
458
|
-
chart_option.series[i].data (values, parallel to xAxis.data)
|
|
459
|
-
donut, pie chart_option.series[0].data ([{name, value}, ...])
|
|
460
|
-
scatter chart_option.series[0].data ([[x, y], ...])
|
|
461
|
-
or chart_option.dataset.source
|
|
462
|
-
table chart_option.dataset.source (first row is the header)
|
|
463
|
-
kpi chart_option.series[0].data[0] (a single number; no axes)
|
|
471
|
+
{ success, conversation_id, log_id, truncated, blocks[] }
|
|
472
|
+
|
|
473
|
+
A turn is an ordered transcript. The agent answers one thing at a time, so a
|
|
474
|
+
single question can return several charts with prose between them, and blocks[]
|
|
475
|
+
preserves that order.
|
|
476
|
+
|
|
477
|
+
blocks[i] is one of:
|
|
478
|
+
|
|
479
|
+
{ type: "text", text }
|
|
480
|
+
What the agent wrote at this point in the answer.
|
|
481
|
+
|
|
482
|
+
{ type: "chart", title, view_spec, chart_type, row_count, columns, rows }
|
|
483
|
+
columns string[] left-to-right column names
|
|
484
|
+
rows [{ <column>: value, ... }] the data, already decoded
|
|
485
|
+
chart_type "bar" | "line" | "donut" | "pie" | "kpi" | "scatter" | "table"
|
|
486
|
+
view_spec the query that produced it, for reuse or refinement
|
|
487
|
+
|
|
488
|
+
truncated: true means the agent ran out of budget partway. The blocks present
|
|
489
|
+
are real and complete; ask a follow-up for the rest.
|
|
490
|
+
|
|
491
|
+
log_id identifies the stored turn, for feedback and for support to trace.
|
|
464
492
|
|
|
465
493
|
EXIT CODES
|
|
466
494
|
0 success
|
|
467
|
-
1
|
|
495
|
+
1 no blocks returned, auth/role/network failure, or a write error
|
|
468
496
|
|
|
469
497
|
INTERACTIVE SESSION
|
|
470
498
|
$ unbound chat start the session
|
|
471
|
-
$ unbound chat --no-suggestions skip the follow-up picker
|
|
472
499
|
|
|
473
500
|
Commands (type inside the session):
|
|
474
501
|
/help show these commands
|
|
@@ -516,7 +543,7 @@ NOTES
|
|
|
516
543
|
}
|
|
517
544
|
|
|
518
545
|
try {
|
|
519
|
-
await runRepl(
|
|
546
|
+
await runRepl();
|
|
520
547
|
} catch (err) {
|
|
521
548
|
output.error(friendlyChatError(err));
|
|
522
549
|
process.exitCode = 1;
|
|
@@ -527,5 +554,8 @@ NOTES
|
|
|
527
554
|
module.exports = {
|
|
528
555
|
register,
|
|
529
556
|
// Exposed for unit tests:
|
|
530
|
-
_internals: {
|
|
557
|
+
_internals: {
|
|
558
|
+
safeWriteFile, expandPath, normalizeAssistantTurn, friendlyChatError,
|
|
559
|
+
toJsonPayload, flattenBlocks, buildBody,
|
|
560
|
+
},
|
|
531
561
|
};
|
|
@@ -203,3 +203,27 @@ test('formatNumber: integer thousands separator, decimal capped', () => {
|
|
|
203
203
|
assert.equal(chart.formatNumber(12.3456), '12.35');
|
|
204
204
|
assert.equal(chart.formatNumber(0), '0');
|
|
205
205
|
});
|
|
206
|
+
|
|
207
|
+
test('reconstructRows: decodes a table chart_option', () => {
|
|
208
|
+
const { columns, rows } = chart.reconstructRows(
|
|
209
|
+
{ table: { columns: ['AI Model', 'Requests'], rows: [{ 'AI Model': 'gpt-4o', Requests: 172 }] } },
|
|
210
|
+
{ chart_type: 'table' },
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
assert.deepEqual(columns, ['AI Model', 'Requests']);
|
|
214
|
+
assert.deepEqual(rows, [{ 'AI Model': 'gpt-4o', Requests: 172 }]);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('reconstructRows: table rows may be arrays aligned to columns', () => {
|
|
218
|
+
const { columns, rows } = chart.reconstructRows(
|
|
219
|
+
{ table: { columns: ['a', 'b'], rows: [['x', 1], ['y', 2]] } },
|
|
220
|
+
{ chart_type: 'table' },
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
assert.deepEqual(columns, ['a', 'b']);
|
|
224
|
+
assert.deepEqual(rows, [{ a: 'x', b: 1 }, { a: 'y', b: 2 }]);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('reconstructRows: a malformed table falls through instead of throwing', () => {
|
|
228
|
+
assert.deepEqual(chart.reconstructRows({ table: { columns: 'nope' } }, {}), { columns: [], rows: [] });
|
|
229
|
+
});
|
|
@@ -142,3 +142,117 @@ test('friendlyChatError: sanitizes ANSI from backend error messages', () => {
|
|
|
142
142
|
const msg = friendlyChatError({ statusCode: 500, message: '\x1b]0;title\x07boom\x1b[31m' });
|
|
143
143
|
assert.equal(msg, 'boom');
|
|
144
144
|
});
|
|
145
|
+
|
|
146
|
+
// --- agent transcript mapping -------------------------------------------------
|
|
147
|
+
|
|
148
|
+
const { toJsonPayload, flattenBlocks, buildBody } = chat._internals;
|
|
149
|
+
|
|
150
|
+
// One chart, shaped the way the agent endpoint returns it: a bar chart whose
|
|
151
|
+
// rows live inside the ECharts option rather than in a rows field.
|
|
152
|
+
function chartBlock(title) {
|
|
153
|
+
return {
|
|
154
|
+
type: 'chart',
|
|
155
|
+
title,
|
|
156
|
+
result: {
|
|
157
|
+
view_spec: { base_table: 'gateway_metrics_summary' },
|
|
158
|
+
row_count: 2,
|
|
159
|
+
chart_meta: { chart_type: 'bar', x: 'dimension', y: ['metric_value'], column_labels: {} },
|
|
160
|
+
chart_option: {
|
|
161
|
+
xAxis: { data: ['openai', 'anthropic'] },
|
|
162
|
+
series: [{ name: 'metric_value', data: [10, 20] }],
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
test('toJsonPayload: keeps prose and charts in the order the agent produced them', () => {
|
|
169
|
+
const payload = toJsonPayload({
|
|
170
|
+
success: true,
|
|
171
|
+
conversation_id: 'c1',
|
|
172
|
+
log_id: 'l1',
|
|
173
|
+
blocks: [
|
|
174
|
+
{ type: 'text', text: 'Here is spend.' },
|
|
175
|
+
chartBlock('Cost by provider'),
|
|
176
|
+
{ type: 'text', text: 'Note the gap.' },
|
|
177
|
+
],
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
assert.deepEqual(payload.blocks.map((b) => b.type), ['text', 'chart', 'text']);
|
|
181
|
+
assert.equal(payload.conversation_id, 'c1');
|
|
182
|
+
assert.equal(payload.log_id, 'l1');
|
|
183
|
+
assert.equal(payload.truncated, false);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('toJsonPayload: decodes chart rows so consumers never parse an ECharts option', () => {
|
|
187
|
+
const payload = toJsonPayload({ blocks: [chartBlock('Cost by provider')] });
|
|
188
|
+
const chartOut = payload.blocks[0];
|
|
189
|
+
|
|
190
|
+
assert.equal(chartOut.title, 'Cost by provider');
|
|
191
|
+
assert.equal(chartOut.chart_type, 'bar');
|
|
192
|
+
assert.equal(chartOut.row_count, 2);
|
|
193
|
+
assert.ok(chartOut.columns.length >= 2, 'columns should be populated');
|
|
194
|
+
assert.equal(chartOut.rows.length, 2);
|
|
195
|
+
assert.deepEqual(chartOut.view_spec, { base_table: 'gateway_metrics_summary' });
|
|
196
|
+
// The wire format is an implementation detail of the browser renderer.
|
|
197
|
+
assert.equal(chartOut.chart_option, undefined);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('toJsonPayload: an undecodable chart still appears, with its spec intact', () => {
|
|
201
|
+
const payload = toJsonPayload({
|
|
202
|
+
blocks: [{ type: 'chart', title: 'Broken', result: { view_spec: { a: 1 }, row_count: 7 } }],
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
assert.equal(payload.blocks.length, 1);
|
|
206
|
+
assert.equal(payload.blocks[0].row_count, 7);
|
|
207
|
+
assert.deepEqual(payload.blocks[0].view_spec, { a: 1 });
|
|
208
|
+
assert.deepEqual(payload.blocks[0].rows, []);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('toJsonPayload: surfaces truncation and failure rather than hiding them', () => {
|
|
212
|
+
assert.equal(toJsonPayload({ blocks: [], truncated: true }).truncated, true);
|
|
213
|
+
assert.equal(toJsonPayload({ success: false, error: 'nope' }).success, false);
|
|
214
|
+
assert.deepEqual(toJsonPayload(null).blocks, []);
|
|
215
|
+
assert.deepEqual(toJsonPayload({ blocks: 'not-an-array' }).blocks, []);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('flattenBlocks: a chart becomes its title, so history carries words only', () => {
|
|
219
|
+
const flat = flattenBlocks([
|
|
220
|
+
{ type: 'text', text: 'First.' },
|
|
221
|
+
{ type: 'chart', title: 'Cost by provider' },
|
|
222
|
+
{ type: 'text', text: 'Second.' },
|
|
223
|
+
]);
|
|
224
|
+
|
|
225
|
+
assert.equal(flat, 'First.\n\n[chart: Cost by provider]\n\nSecond.');
|
|
226
|
+
assert.equal(flattenBlocks(undefined), '');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('buildBody: omits conversation_id on the first turn, sends it after', () => {
|
|
230
|
+
assert.equal('conversation_id' in buildBody('hi', [], null), false);
|
|
231
|
+
assert.equal(buildBody('hi', [], 'c9').conversation_id, 'c9');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('toJsonPayload: carries the failure reason, not just the failure', () => {
|
|
235
|
+
const payload = toJsonPayload({ success: false, error: 'The assistant is unavailable right now.' });
|
|
236
|
+
|
|
237
|
+
assert.equal(payload.success, false);
|
|
238
|
+
// Without this a --json consumer gets an exit code and nothing to act on.
|
|
239
|
+
assert.equal(payload.error, 'The assistant is unavailable right now.');
|
|
240
|
+
assert.equal(toJsonPayload({ blocks: [] }).error, null);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test('toJsonPayload: an empty chart and an unreadable one are not the same block', () => {
|
|
244
|
+
const unreadable = { get table() { throw new TypeError('unreadable'); } };
|
|
245
|
+
const payload = toJsonPayload({
|
|
246
|
+
blocks: [
|
|
247
|
+
{ type: 'chart', title: 'Broken', result: { chart_option: unreadable, view_spec: { a: 1 }, row_count: 7 } },
|
|
248
|
+
{ type: 'chart', title: 'Empty', result: { chart_option: {}, view_spec: { b: 2 }, row_count: 0 } },
|
|
249
|
+
],
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
assert.equal(payload.blocks[0].decode_failed, true);
|
|
253
|
+
assert.deepEqual(payload.blocks[0].rows, []);
|
|
254
|
+
// The spec survives, so the caller still knows what was asked.
|
|
255
|
+
assert.deepEqual(payload.blocks[0].view_spec, { a: 1 });
|
|
256
|
+
// A genuinely empty answer carries no such marker.
|
|
257
|
+
assert.equal('decode_failed' in payload.blocks[1], false);
|
|
258
|
+
});
|