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.
Files changed (3) hide show
  1. package/bin/prior.js +67 -10
  2. package/lib/agent.js +105 -24
  3. 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
- function renderToolDone(name, summary) {
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 = () => c.brand(' ❯ ');
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
- rl.question(PROMPT(), async raw => {
774
+ const isML = _mlBuf.length > 0;
775
+ rl.question(isML ? ML_PROMPT() : PROMPT(), async raw => {
757
776
  clearSuggestions();
758
- const input = raw.trim();
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 = false;
1000
- const _thinkStart = Date.now();
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 (server just runs Ollama + returns)
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
- return await res.json(); // { content, promptTokens, completionTokens }
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 = getToken();
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; // only sent on first iteration
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
- let result;
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; // clear after first use
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
- const textBefore = stripToolTags(cleaned.slice(0, calls[0].offset)).trim();
268
- if (textBefore) send({ type: 'text', content: textBefore });
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
- send({ type: 'tool_done', name: call.name, summary: toolResult.summary });
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 });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prior-cli",
3
- "version": "1.3.12",
3
+ "version": "1.4.0",
4
4
  "description": "Prior Network AI — command-line interface",
5
5
  "bin": {
6
6
  "prior": "bin/prior.js"