prior-cli 1.3.12 → 1.4.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/bin/prior.js +67 -10
- package/lib/agent.js +105 -24
- package/package.json +1 -1
package/bin/prior.js
CHANGED
|
@@ -388,7 +388,9 @@ function hyperlink(text, url) {
|
|
|
388
388
|
return `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\`;
|
|
389
389
|
}
|
|
390
390
|
|
|
391
|
-
|
|
391
|
+
const PREVIEW_TOOLS = new Set(['file_read', 'run_command', 'web_search', 'url_fetch']);
|
|
392
|
+
|
|
393
|
+
function renderToolDone(name, summary, preview) {
|
|
392
394
|
const took = _toolStartTime ? c.dim(` · ${elapsed(Date.now() - _toolStartTime)}`) : '';
|
|
393
395
|
let display = summary || '';
|
|
394
396
|
if (/^[a-zA-Z]:[/\\]/.test(display) || display.startsWith('/')) {
|
|
@@ -398,6 +400,19 @@ function renderToolDone(name, summary) {
|
|
|
398
400
|
display = c.dim(display);
|
|
399
401
|
}
|
|
400
402
|
process.stdout.write(` ${c.ok('✓')} ${c.muted(name)} ${display}${took}\n`);
|
|
403
|
+
|
|
404
|
+
// Rich preview for certain tools
|
|
405
|
+
if (preview && PREVIEW_TOOLS.has(name)) {
|
|
406
|
+
const lines = String(preview).split('\n').filter(l => l.trim());
|
|
407
|
+
const toShow = lines.slice(0, 5);
|
|
408
|
+
const more = lines.length - toShow.length;
|
|
409
|
+
if (toShow.length > 0) {
|
|
410
|
+
drawBox([
|
|
411
|
+
...toShow.map(l => ({ text: l.slice(0, 80), dim: true })),
|
|
412
|
+
...(more > 0 ? [{ text: `… ${more} more line${more !== 1 ? 's' : ''}`, dim: true }] : []),
|
|
413
|
+
]);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
401
416
|
}
|
|
402
417
|
|
|
403
418
|
function renderToolError(name, error) {
|
|
@@ -750,12 +765,25 @@ async function startChat(opts = {}) {
|
|
|
750
765
|
process.exit(0);
|
|
751
766
|
});
|
|
752
767
|
|
|
753
|
-
const PROMPT
|
|
768
|
+
const PROMPT = () => c.brand(' ❯ ');
|
|
769
|
+
const ML_PROMPT = () => c.brand(' … ');
|
|
770
|
+
|
|
771
|
+
let _mlBuf = []; // multiline accumulation (\ continuation)
|
|
754
772
|
|
|
755
773
|
const loop = () => {
|
|
756
|
-
|
|
774
|
+
const isML = _mlBuf.length > 0;
|
|
775
|
+
rl.question(isML ? ML_PROMPT() : PROMPT(), async raw => {
|
|
757
776
|
clearSuggestions();
|
|
758
|
-
|
|
777
|
+
|
|
778
|
+
// Backslash continuation — collect lines until one doesn't end with \
|
|
779
|
+
if (raw.endsWith('\\')) {
|
|
780
|
+
_mlBuf.push(raw.slice(0, -1));
|
|
781
|
+
return loop();
|
|
782
|
+
}
|
|
783
|
+
_mlBuf.push(raw);
|
|
784
|
+
const input = _mlBuf.join('\n').trim();
|
|
785
|
+
_mlBuf = [];
|
|
786
|
+
|
|
759
787
|
if (!input) return loop();
|
|
760
788
|
|
|
761
789
|
// ── Slash commands ──────────────────────────────────────
|
|
@@ -995,9 +1023,10 @@ Keep it under 350 words. Write prior.md now.`;
|
|
|
995
1023
|
console.log(c.brand(' ◈') + c.dim(` ${label} attached`));
|
|
996
1024
|
}
|
|
997
1025
|
|
|
998
|
-
let responseText
|
|
999
|
-
let _progressStarted
|
|
1000
|
-
|
|
1026
|
+
let responseText = '';
|
|
1027
|
+
let _progressStarted = false;
|
|
1028
|
+
let _streamStarted = false; // true after first text_chunk
|
|
1029
|
+
const _thinkStart = Date.now();
|
|
1001
1030
|
|
|
1002
1031
|
spinStart('thinking…');
|
|
1003
1032
|
|
|
@@ -1030,14 +1059,42 @@ Keep it under 350 words. Write prior.md now.`;
|
|
|
1030
1059
|
spinStart('thinking…');
|
|
1031
1060
|
break;
|
|
1032
1061
|
|
|
1062
|
+
case 'waiting':
|
|
1063
|
+
spinStart(`waiting for Ollama… (${ev.attempt}/${ev.max})`);
|
|
1064
|
+
break;
|
|
1065
|
+
|
|
1033
1066
|
case 'cancelled':
|
|
1034
1067
|
spinStop();
|
|
1035
|
-
process.stdout.write('\n');
|
|
1068
|
+
if (_streamStarted) process.stdout.write('\n');
|
|
1036
1069
|
console.log(c.muted(' ✗ Cancelled'));
|
|
1037
1070
|
break;
|
|
1038
1071
|
|
|
1072
|
+
// ── Streaming text events ──────────────────────
|
|
1073
|
+
case 'stream_start': {
|
|
1074
|
+
spinStop();
|
|
1075
|
+
const thinkTime = elapsed(Date.now() - _thinkStart);
|
|
1076
|
+
console.log(c.brand(' Prior ') + c.muted(`· ${timeNow()} · ${thinkTime}`));
|
|
1077
|
+
console.log('');
|
|
1078
|
+
process.stdout.write(' ');
|
|
1079
|
+
_streamStarted = true;
|
|
1080
|
+
break;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
case 'text_chunk':
|
|
1084
|
+
if (ev.content) {
|
|
1085
|
+
process.stdout.write(ev.content);
|
|
1086
|
+
responseText += ev.content;
|
|
1087
|
+
}
|
|
1088
|
+
break;
|
|
1089
|
+
|
|
1090
|
+
case 'stream_end':
|
|
1091
|
+
process.stdout.write('\n');
|
|
1092
|
+
_streamStarted = false;
|
|
1093
|
+
break;
|
|
1094
|
+
|
|
1039
1095
|
case 'tool_start':
|
|
1040
1096
|
spinStop();
|
|
1097
|
+
if (_streamStarted) { process.stdout.write('\n'); _streamStarted = false; }
|
|
1041
1098
|
_progressStarted = false;
|
|
1042
1099
|
renderToolStart(ev.name, ev.args);
|
|
1043
1100
|
if (!CONFIRM_TOOLS.has(ev.name)) spinStart('working…');
|
|
@@ -1060,7 +1117,7 @@ Keep it under 350 words. Write prior.md now.`;
|
|
|
1060
1117
|
|
|
1061
1118
|
case 'tool_done':
|
|
1062
1119
|
spinStop();
|
|
1063
|
-
renderToolDone(ev.name, ev.summary);
|
|
1120
|
+
renderToolDone(ev.name, ev.summary, ev.preview);
|
|
1064
1121
|
break;
|
|
1065
1122
|
|
|
1066
1123
|
case 'tool_skip':
|
|
@@ -1091,7 +1148,7 @@ Keep it under 350 words. Write prior.md now.`;
|
|
|
1091
1148
|
|
|
1092
1149
|
case 'error':
|
|
1093
1150
|
spinStop();
|
|
1094
|
-
process.stdout.write('\n');
|
|
1151
|
+
if (_streamStarted) process.stdout.write('\n');
|
|
1095
1152
|
console.error(c.err(` ✗ ${ev.message}`));
|
|
1096
1153
|
break;
|
|
1097
1154
|
}
|
package/lib/agent.js
CHANGED
|
@@ -8,9 +8,9 @@ const CLI_BASE = 'https://prior.ngrok.app/cli-backend';
|
|
|
8
8
|
const PRIOR_BASE = 'https://prior.ngrok.app';
|
|
9
9
|
const MAX_ITER = 14;
|
|
10
10
|
|
|
11
|
-
// ── Single inference call
|
|
11
|
+
// ── Single inference call — reads NDJSON stream from backend ──
|
|
12
12
|
|
|
13
|
-
async function infer(messages, model, token, { cwd, uncensored, projectContext, images } = {}, signal) {
|
|
13
|
+
async function infer(messages, model, token, { cwd, uncensored, projectContext, images } = {}, signal, onChunk) {
|
|
14
14
|
const res = await fetch(`${CLI_BASE}/api/infer`, {
|
|
15
15
|
method: 'POST',
|
|
16
16
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -18,11 +18,49 @@ async function infer(messages, model, token, { cwd, uncensored, projectContext,
|
|
|
18
18
|
timeout: 120000,
|
|
19
19
|
signal,
|
|
20
20
|
});
|
|
21
|
+
|
|
21
22
|
if (!res.ok) {
|
|
22
23
|
const err = await res.json().catch(() => ({}));
|
|
23
24
|
throw new Error(err.error || `Server error: HTTP ${res.status}`);
|
|
24
25
|
}
|
|
25
|
-
|
|
26
|
+
|
|
27
|
+
let content = '';
|
|
28
|
+
let promptTokens = 0;
|
|
29
|
+
let completionTokens = 0;
|
|
30
|
+
|
|
31
|
+
await new Promise((resolve, reject) => {
|
|
32
|
+
let buf = '';
|
|
33
|
+
res.body.on('data', rawChunk => {
|
|
34
|
+
if (signal?.aborted) {
|
|
35
|
+
res.body.destroy();
|
|
36
|
+
return reject(Object.assign(new Error('AbortError'), { name: 'AbortError' }));
|
|
37
|
+
}
|
|
38
|
+
buf += rawChunk.toString();
|
|
39
|
+
const lines = buf.split('\n');
|
|
40
|
+
buf = lines.pop();
|
|
41
|
+
for (const line of lines) {
|
|
42
|
+
if (!line.trim()) continue;
|
|
43
|
+
try {
|
|
44
|
+
const data = JSON.parse(line);
|
|
45
|
+
if (data.type === 'chunk') {
|
|
46
|
+
if (onChunk) onChunk(data.content);
|
|
47
|
+
} else if (data.type === 'done') {
|
|
48
|
+
content = data.content || '';
|
|
49
|
+
promptTokens = data.promptTokens || 0;
|
|
50
|
+
completionTokens = data.completionTokens || 0;
|
|
51
|
+
} else if (data.type === 'waiting') {
|
|
52
|
+
if (onChunk) onChunk(null, { type: 'waiting', attempt: data.attempt, max: data.max });
|
|
53
|
+
} else if (data.type === 'error') {
|
|
54
|
+
reject(new Error(data.message));
|
|
55
|
+
}
|
|
56
|
+
} catch { /* skip malformed line */ }
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
res.body.on('end', resolve);
|
|
60
|
+
res.body.on('error', reject);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return { content, promptTokens, completionTokens };
|
|
26
64
|
}
|
|
27
65
|
|
|
28
66
|
// ── Token usage tracking ──────────────────────────────────────
|
|
@@ -201,40 +239,82 @@ function stripToolTags(text) {
|
|
|
201
239
|
const CONFIRM_TOOLS = new Set(['run_command', 'file_delete', 'file_write']);
|
|
202
240
|
|
|
203
241
|
async function runAgent({ messages, model, uncensored, cwd, projectContext, images, send, confirm, signal }) {
|
|
204
|
-
const token
|
|
242
|
+
const token = getToken();
|
|
205
243
|
const history = [...messages];
|
|
206
244
|
|
|
207
245
|
let totalPromptTokens = 0;
|
|
208
246
|
let totalCompletionTokens = 0;
|
|
209
|
-
let pendingImages = (images && images.length) ? images : null;
|
|
247
|
+
let pendingImages = (images && images.length) ? images : null;
|
|
210
248
|
|
|
211
249
|
for (let iter = 0; iter < MAX_ITER; iter++) {
|
|
212
250
|
|
|
213
|
-
if (signal?.aborted) {
|
|
214
|
-
send({ type: 'cancelled' });
|
|
215
|
-
send({ type: 'done' });
|
|
216
|
-
return;
|
|
217
|
-
}
|
|
251
|
+
if (signal?.aborted) { send({ type: 'cancelled' }); send({ type: 'done' }); return; }
|
|
218
252
|
|
|
219
253
|
send({ type: 'thinking' });
|
|
220
254
|
|
|
221
|
-
|
|
255
|
+
// ── Per-iteration streaming state ─────────────────────────
|
|
256
|
+
// After </think> we buffer LOOK_SIZE chars to detect tool calls before
|
|
257
|
+
// deciding whether to stream text live or stay in buffered mode.
|
|
258
|
+
let thinkBuf = ''; // chunks buffered while inside <think>…</think>
|
|
259
|
+
let thinkDone = false;
|
|
260
|
+
let lookBuf = ''; // first N chars of actual response (after think)
|
|
261
|
+
let streaming = false; // true once we've committed to live-streaming text
|
|
262
|
+
const LOOK_SIZE = 60;
|
|
263
|
+
|
|
264
|
+
function tryStartStreaming() {
|
|
265
|
+
if (streaming) return;
|
|
266
|
+
const trimmed = lookBuf.replace(/^[\s\n]+/, '');
|
|
267
|
+
// If the response starts with a tool tag, keep buffered (no live text)
|
|
268
|
+
if (/^<(?:tool|write|append|docx)[\s>{"[]/.test(trimmed)) return;
|
|
269
|
+
streaming = true;
|
|
270
|
+
send({ type: 'stream_start' });
|
|
271
|
+
if (trimmed) send({ type: 'text_chunk', content: trimmed });
|
|
272
|
+
lookBuf = '';
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const onChunk = (raw, meta) => {
|
|
276
|
+
if (meta?.type === 'waiting') { send({ type: 'waiting', attempt: meta.attempt, max: meta.max }); return; }
|
|
277
|
+
if (!raw) return;
|
|
278
|
+
|
|
279
|
+
if (!thinkDone) {
|
|
280
|
+
thinkBuf += raw;
|
|
281
|
+
const idx = thinkBuf.indexOf('</think>');
|
|
282
|
+
if (idx !== -1) {
|
|
283
|
+
thinkDone = true;
|
|
284
|
+
lookBuf = thinkBuf.slice(idx + 8).replace(/^[\s\n]+/, '');
|
|
285
|
+
thinkBuf = '';
|
|
286
|
+
if (lookBuf.length >= LOOK_SIZE) tryStartStreaming();
|
|
287
|
+
}
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (!streaming) {
|
|
292
|
+
lookBuf += raw;
|
|
293
|
+
if (lookBuf.length >= LOOK_SIZE) tryStartStreaming();
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
send({ type: 'text_chunk', content: raw });
|
|
298
|
+
};
|
|
299
|
+
|
|
222
300
|
const iterImages = pendingImages;
|
|
223
|
-
pendingImages = null;
|
|
301
|
+
pendingImages = null;
|
|
302
|
+
|
|
303
|
+
let result;
|
|
224
304
|
try {
|
|
225
|
-
result = await infer(history, model || 'qwen3.5:4b', token, { cwd, uncensored, projectContext, images: iterImages }, signal);
|
|
305
|
+
result = await infer(history, model || 'qwen3.5:4b', token, { cwd, uncensored, projectContext, images: iterImages }, signal, onChunk);
|
|
226
306
|
} catch (err) {
|
|
227
307
|
await trackTokenUsage(token, totalPromptTokens, totalCompletionTokens);
|
|
228
|
-
if (err.name === 'AbortError' || signal?.aborted) {
|
|
229
|
-
send({ type: 'cancelled' });
|
|
230
|
-
send({ type: 'done' });
|
|
231
|
-
return;
|
|
232
|
-
}
|
|
308
|
+
if (err.name === 'AbortError' || signal?.aborted) { send({ type: 'cancelled' }); send({ type: 'done' }); return; }
|
|
233
309
|
send({ type: 'error', message: err.message });
|
|
234
310
|
send({ type: 'done' });
|
|
235
311
|
return;
|
|
236
312
|
}
|
|
237
313
|
|
|
314
|
+
// Flush look-ahead if stream ended before LOOK_SIZE was reached
|
|
315
|
+
if (thinkDone && !streaming && lookBuf) tryStartStreaming();
|
|
316
|
+
if (streaming) send({ type: 'stream_end' });
|
|
317
|
+
|
|
238
318
|
totalPromptTokens += result.promptTokens || 0;
|
|
239
319
|
totalCompletionTokens += result.completionTokens || 0;
|
|
240
320
|
|
|
@@ -252,20 +332,21 @@ async function runAgent({ messages, model, uncensored, cwd, projectContext, imag
|
|
|
252
332
|
if (calls.length === 0) {
|
|
253
333
|
const finalText = stripToolTags(cleaned);
|
|
254
334
|
if (!finalText && iter < MAX_ITER - 1) {
|
|
255
|
-
// Model returned blank (all think tags, no actual output) — nudge once
|
|
256
335
|
history.push({ role: 'assistant', content: raw });
|
|
257
336
|
history.push({ role: 'user', content: '(Your response was empty. Please write your reply.)' });
|
|
258
337
|
continue;
|
|
259
338
|
}
|
|
260
339
|
await trackTokenUsage(token, totalPromptTokens, totalCompletionTokens);
|
|
261
|
-
send({ type: 'text', content: finalText });
|
|
340
|
+
if (!streaming) send({ type: 'text', content: finalText }); // already shown if streaming
|
|
262
341
|
send({ type: 'done' });
|
|
263
342
|
return;
|
|
264
343
|
}
|
|
265
344
|
|
|
266
345
|
// ── Text before first tool call ───────────────────────────
|
|
267
|
-
|
|
268
|
-
|
|
346
|
+
if (!streaming) {
|
|
347
|
+
const textBefore = stripToolTags(cleaned.slice(0, calls[0].offset)).trim();
|
|
348
|
+
if (textBefore) send({ type: 'text', content: textBefore });
|
|
349
|
+
}
|
|
269
350
|
|
|
270
351
|
history.push({ role: 'assistant', content: raw });
|
|
271
352
|
|
|
@@ -274,7 +355,6 @@ async function runAgent({ messages, model, uncensored, cwd, projectContext, imag
|
|
|
274
355
|
for (const call of calls) {
|
|
275
356
|
send({ type: 'tool_start', name: call.name, args: call.args });
|
|
276
357
|
|
|
277
|
-
// Confirmation gate for destructive / side-effect tools
|
|
278
358
|
if (confirm && CONFIRM_TOOLS.has(call.name)) {
|
|
279
359
|
const approved = await confirm({ name: call.name, args: call.args });
|
|
280
360
|
if (!approved) {
|
|
@@ -286,7 +366,8 @@ async function runAgent({ messages, model, uncensored, cwd, projectContext, imag
|
|
|
286
366
|
|
|
287
367
|
try {
|
|
288
368
|
const toolResult = await executeTool(call.name, call.args, { cwd, token, send });
|
|
289
|
-
|
|
369
|
+
// Pass output snippet so the CLI can show a rich preview
|
|
370
|
+
send({ type: 'tool_done', name: call.name, summary: toolResult.summary, preview: toolResult.output });
|
|
290
371
|
resultParts.push(`<tool_result name="${call.name}">\n${toolResult.output}\n</tool_result>`);
|
|
291
372
|
} catch (err) {
|
|
292
373
|
send({ type: 'tool_error', name: call.name, error: err.message });
|