prior-cli 1.3.11 → 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 +94 -34
  2. package/lib/agent.js +107 -26
  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) {
@@ -608,7 +623,7 @@ async function startChat(opts = {}) {
608
623
  const chatHistory = [];
609
624
  let currentModel = opts.model || null;
610
625
  let _currentAbortController = null;
611
- let _pendingImageBase64 = null; // set by alt+v clipboard paste
626
+ let _pendingImages = []; // set by alt+v clipboard paste (supports multiple)
612
627
 
613
628
  // ── Live slash-command suggestions ──────────────────────────
614
629
  let clearSuggestions = () => {};
@@ -652,8 +667,12 @@ async function startChat(opts = {}) {
652
667
  let rows = 0;
653
668
 
654
669
  // Image indicator — always first, persists across backspace/typing
655
- if (_pendingImageBase64) {
656
- process.stdout.write(`\x1b[B\r\x1b[2K ${c.brand('◈')} ${c.dim('[Image] attached · alt+v to remove')}`);
670
+ if (_pendingImages.length > 0) {
671
+ const tags = _pendingImages.map((_, i) => c.brand(`[Image ${i + 1}]`)).join(' ');
672
+ const hint = _pendingImages.length > 0
673
+ ? c.dim(' · alt+v to add more · alt+v (empty clipboard) to remove last')
674
+ : '';
675
+ process.stdout.write(`\x1b[B\r\x1b[2K ${c.brand('◈')} ${tags}${hint}`);
657
676
  rows++;
658
677
  }
659
678
 
@@ -690,24 +709,22 @@ async function startChat(opts = {}) {
690
709
  return;
691
710
  }
692
711
 
693
- // Alt+V — grab image from clipboard, or remove if already attached
712
+ // Alt+V — add image from clipboard, or remove last if clipboard is empty
694
713
  if (key.meta && key.name === 'v') {
695
- if (_pendingImageBase64) {
696
- _pendingImageBase64 = null;
697
- renderSubRows(rl.line || '');
698
- } else {
699
- try {
700
- const ps = `Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [Convert]::ToBase64String($ms.ToArray()) } else { '' }`;
701
- const b64 = execSync(`powershell -NoProfile -Command "${ps}"`, { timeout: 5000 }).toString().trim();
702
- if (b64) {
703
- _pendingImageBase64 = b64;
704
- renderSubRows(rl.line || '');
705
- } else {
706
- flashSubRow(c.muted('✗ No image found in clipboard'));
707
- }
708
- } catch {
709
- flashSubRow(c.muted('✗ Could not read clipboard'));
714
+ try {
715
+ const ps = `Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [Convert]::ToBase64String($ms.ToArray()) } else { '' }`;
716
+ const b64 = execSync(`powershell -NoProfile -Command "${ps}"`, { timeout: 5000 }).toString().trim();
717
+ if (b64) {
718
+ _pendingImages.push(b64);
719
+ renderSubRows(rl.line || '');
720
+ } else if (_pendingImages.length > 0) {
721
+ _pendingImages.pop();
722
+ renderSubRows(rl.line || '');
723
+ } else {
724
+ flashSubRow(c.muted('✗ No image found in clipboard'));
710
725
  }
726
+ } catch {
727
+ flashSubRow(c.muted('✗ Could not read clipboard'));
711
728
  }
712
729
  return;
713
730
  }
@@ -748,12 +765,25 @@ async function startChat(opts = {}) {
748
765
  process.exit(0);
749
766
  });
750
767
 
751
- const PROMPT = () => c.brand(' ❯ ');
768
+ const PROMPT = () => c.brand(' ❯ ');
769
+ const ML_PROMPT = () => c.brand(' … ');
770
+
771
+ let _mlBuf = []; // multiline accumulation (\ continuation)
752
772
 
753
773
  const loop = () => {
754
- rl.question(PROMPT(), async raw => {
774
+ const isML = _mlBuf.length > 0;
775
+ rl.question(isML ? ML_PROMPT() : PROMPT(), async raw => {
755
776
  clearSuggestions();
756
- 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
+
757
787
  if (!input) return loop();
758
788
 
759
789
  // ── Slash commands ──────────────────────────────────────
@@ -985,16 +1015,18 @@ Keep it under 350 words. Write prior.md now.`;
985
1015
  console.log('');
986
1016
 
987
1017
  {
988
- const imageForThisMsg = _pendingImageBase64;
989
- _pendingImageBase64 = null;
1018
+ const imagesForThisMsg = [..._pendingImages];
1019
+ _pendingImages = [];
990
1020
 
991
- if (imageForThisMsg) {
992
- console.log(c.brand('') + c.dim(' image attached'));
1021
+ if (imagesForThisMsg.length > 0) {
1022
+ const label = imagesForThisMsg.length === 1 ? '1 image' : `${imagesForThisMsg.length} images`;
1023
+ console.log(c.brand(' ◈') + c.dim(` ${label} attached`));
993
1024
  }
994
1025
 
995
- let responseText = '';
996
- let _progressStarted = false;
997
- 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();
998
1030
 
999
1031
  spinStart('thinking…');
1000
1032
 
@@ -1017,7 +1049,7 @@ Keep it under 350 words. Write prior.md now.`;
1017
1049
  model: currentModel,
1018
1050
  cwd: process.cwd(),
1019
1051
  projectContext,
1020
- imageBase64: imageForThisMsg,
1052
+ images: imagesForThisMsg,
1021
1053
  confirm,
1022
1054
  signal: _currentAbortController.signal,
1023
1055
  send: ev => {
@@ -1027,14 +1059,42 @@ Keep it under 350 words. Write prior.md now.`;
1027
1059
  spinStart('thinking…');
1028
1060
  break;
1029
1061
 
1062
+ case 'waiting':
1063
+ spinStart(`waiting for Ollama… (${ev.attempt}/${ev.max})`);
1064
+ break;
1065
+
1030
1066
  case 'cancelled':
1031
1067
  spinStop();
1032
- process.stdout.write('\n');
1068
+ if (_streamStarted) process.stdout.write('\n');
1033
1069
  console.log(c.muted(' ✗ Cancelled'));
1034
1070
  break;
1035
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
+
1036
1095
  case 'tool_start':
1037
1096
  spinStop();
1097
+ if (_streamStarted) { process.stdout.write('\n'); _streamStarted = false; }
1038
1098
  _progressStarted = false;
1039
1099
  renderToolStart(ev.name, ev.args);
1040
1100
  if (!CONFIRM_TOOLS.has(ev.name)) spinStart('working…');
@@ -1057,7 +1117,7 @@ Keep it under 350 words. Write prior.md now.`;
1057
1117
 
1058
1118
  case 'tool_done':
1059
1119
  spinStop();
1060
- renderToolDone(ev.name, ev.summary);
1120
+ renderToolDone(ev.name, ev.summary, ev.preview);
1061
1121
  break;
1062
1122
 
1063
1123
  case 'tool_skip':
@@ -1088,7 +1148,7 @@ Keep it under 350 words. Write prior.md now.`;
1088
1148
 
1089
1149
  case 'error':
1090
1150
  spinStop();
1091
- process.stdout.write('\n');
1151
+ if (_streamStarted) process.stdout.write('\n');
1092
1152
  console.error(c.err(` ✗ ${ev.message}`));
1093
1153
  break;
1094
1154
  }
package/lib/agent.js CHANGED
@@ -8,21 +8,59 @@ 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, imageBase64 } = {}, 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' },
17
- body: JSON.stringify({ messages, model, token, cwd, uncensored, projectContext, imageBase64 }),
17
+ body: JSON.stringify({ messages, model, token, cwd, uncensored, projectContext, images }),
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 ──────────────────────────────────────
@@ -200,41 +238,83 @@ function stripToolTags(text) {
200
238
 
201
239
  const CONFIRM_TOOLS = new Set(['run_command', 'file_delete', 'file_write']);
202
240
 
203
- async function runAgent({ messages, model, uncensored, cwd, projectContext, imageBase64, send, confirm, signal }) {
204
- const token = getToken();
241
+ async function runAgent({ messages, model, uncensored, cwd, projectContext, images, send, confirm, signal }) {
242
+ const token = getToken();
205
243
  const history = [...messages];
206
244
 
207
245
  let totalPromptTokens = 0;
208
246
  let totalCompletionTokens = 0;
209
- let pendingImage = imageBase64 || 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
 
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
+
300
+ const iterImages = pendingImages;
301
+ pendingImages = null;
302
+
221
303
  let result;
222
- const iterImage = pendingImage;
223
- pendingImage = null; // clear after first use
224
304
  try {
225
- result = await infer(history, model || 'qwen3.5:4b', token, { cwd, uncensored, projectContext, imageBase64: iterImage }, 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.11",
3
+ "version": "1.4.0",
4
4
  "description": "Prior Network AI — command-line interface",
5
5
  "bin": {
6
6
  "prior": "bin/prior.js"