@lzhzzzzwill/cofos 1.2.4 → 1.3.1

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/README.md +16 -0
  2. package/bin/cofos.js +253 -53
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -6,6 +6,8 @@ COFOS CLI is a standalone Node.js command line wrapper around the COFOS Python r
6
6
  npx @lzhzzzzwill/cofos
7
7
  ```
8
8
 
9
+ By default COFOS uses a plain terminal interface, so normal terminal scrollback, mouse selection, copy, and `Ctrl+C` work as expected. The fixed-input full-screen interface is still available as an experimental mode with `--tui`.
10
+
9
11
  ## Runtime Model
10
12
 
11
13
  The standalone npm package includes a minimal Python runtime under `runtime/`:
@@ -69,6 +71,18 @@ Disable RAG for a session:
69
71
  npx @lzhzzzzwill/cofos --no-rag
70
72
  ```
71
73
 
74
+ Ask once and exit, useful for scripts and smoke tests:
75
+
76
+ ```bash
77
+ npx @lzhzzzzwill/cofos -p "What ROS does TT-T-COF generate under visible-light photocatalysis?"
78
+ ```
79
+
80
+ Use the experimental fixed-input TUI:
81
+
82
+ ```bash
83
+ npx @lzhzzzzwill/cofos --tui
84
+ ```
85
+
72
86
  ## Startup Behavior
73
87
 
74
88
  On startup, the CLI does the following:
@@ -118,6 +132,8 @@ cofos \
118
132
  | `--cache-dir` | Folder for model, KG/BM25, and parsed PDF cache; defaults to `./.cofos` |
119
133
  | `--rebuild-pdf-index` | Force PDF parsing and BM25 rebuild |
120
134
  | `--top-k` | Retrieval top-k |
135
+ | `-p`, `--prompt` | Ask one question and exit |
136
+ | `--tui` | Use the experimental fixed-input full-screen TUI |
121
137
  | `--max-new-tokens` | Override generation length |
122
138
  | `--no-rag` | Run without KG/BM25 retrieval |
123
139
 
package/bin/cofos.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn, spawnSync } from "node:child_process";
3
- import blessed from "blessed";
3
+ import { createInterface, emitKeypressEvents } from "node:readline";
4
4
  import { existsSync, readFileSync } from "node:fs";
5
5
  import { dirname, join, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
@@ -134,6 +134,12 @@ function slashHint(line = "/") {
134
134
  return `\n Commands\n${rows}\n Press Tab to complete.\n`;
135
135
  }
136
136
 
137
+ function slashInlineHint(line = "/") {
138
+ const hits = slashMatches(line);
139
+ if (!hits.length) return "";
140
+ return hits.map(({ cmd, desc }) => ` ${C}${cmd}${R} ${desc}`).join("\n");
141
+ }
142
+
137
143
  /* ─── helpers ─── */
138
144
 
139
145
  function cmdExist(c) { return spawnSync(c, ["--version"], { encoding: "utf-8", stdio: "pipe" }).status === 0; }
@@ -154,12 +160,15 @@ function ensureDeps(py, log = console.log) {
154
160
 
155
161
  /* ─── TUI ─── */
156
162
 
157
- function createTui() {
163
+ async function createTui() {
164
+ const { default: blessed } = await import("blessed");
158
165
  const screen = blessed.screen({
159
166
  smartCSR: true,
160
167
  fullUnicode: true,
161
168
  title: "COFOS",
169
+ mouse: false,
162
170
  });
171
+ screen.program.disableMouse();
163
172
 
164
173
  const history = blessed.box({
165
174
  top: 0,
@@ -170,7 +179,7 @@ function createTui() {
170
179
  alwaysScroll: true,
171
180
  keys: true,
172
181
  vi: true,
173
- mouse: true,
182
+ mouse: false,
174
183
  tags: true,
175
184
  scrollbar: { ch: " ", track: { bg: "black" }, style: { bg: "cyan" } },
176
185
  });
@@ -213,6 +222,12 @@ function createTui() {
213
222
  let suggestionMatches = [];
214
223
  let selectedSuggestion = 0;
215
224
 
225
+ function renderBuffer(wasAtBottom = true) {
226
+ history.setContent(buffer);
227
+ if (wasAtBottom) history.setScrollPerc(100);
228
+ screen.render();
229
+ }
230
+
216
231
  function append(text = "") {
217
232
  const wasAtBottom = history.getScrollPerc() >= 98;
218
233
  const data = String(text).replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
@@ -226,9 +241,7 @@ function createTui() {
226
241
  }
227
242
  }
228
243
  if (buffer.length > 250000) buffer = buffer.slice(-220000);
229
- history.setContent(buffer);
230
- if (wasAtBottom) history.setScrollPerc(100);
231
- screen.render();
244
+ renderBuffer(wasAtBottom);
232
245
  }
233
246
 
234
247
  function hideSuggestions() {
@@ -326,25 +339,100 @@ function createTui() {
326
339
  screen.render();
327
340
 
328
341
  return {
342
+ supportsReplace: true,
329
343
  screen,
330
344
  input,
331
345
  append,
332
346
  print: (text = "") => append(String(text) + "\n"),
333
- clear: () => { buffer = ""; history.setContent(""); history.setScrollPerc(100); screen.render(); },
347
+ clear: () => { buffer = ""; renderBuffer(true); },
348
+ length: () => buffer.length,
349
+ replaceFrom: (index, text = "") => { buffer = buffer.slice(0, Math.max(0, index)) + String(text); renderBuffer(true); },
334
350
  setSubmitHandler: (fn) => { onSubmit = fn; },
335
351
  setExitHandler: (fn) => { onExit = fn; },
336
352
  focus: () => { input.focus(); screen.render(); },
337
353
  setPrompt: (label) => { input.setLabel(` ${label} `); screen.render(); },
338
- destroy: () => screen.destroy(),
354
+ destroy: () => {
355
+ try { screen.program.disableMouse(); } catch (_) {}
356
+ screen.destroy();
357
+ },
358
+ };
359
+ }
360
+
361
+ function blessedToAnsi(text) {
362
+ return String(text)
363
+ .replace(/\{bold\}/g, B)
364
+ .replace(/\{\/bold\}/g, R)
365
+ .replace(/\{cyan-fg\}/g, C)
366
+ .replace(/\{yellow-fg\}/g, Y)
367
+ .replace(/\{green-fg\}/g, GR)
368
+ .replace(/\{gray-fg\}/g, G)
369
+ .replace(/\{red-fg\}/g, "\x1b[91m")
370
+ .replace(/\{black-fg\}|\{yellow-bg\}|\{\/yellow-bg\}|\{\/black-fg\}/g, "")
371
+ .replace(/\{\/(?:cyan-fg|yellow-fg|green-fg|gray-fg|red-fg)\}/g, R)
372
+ .replace(/[{}]/g, "");
373
+ }
374
+
375
+ function createPlainUi() {
376
+ const rl = createInterface({
377
+ input: process.stdin,
378
+ output: process.stdout,
379
+ terminal: true,
380
+ completer: (line) => slashCompletions(line),
381
+ });
382
+ let onSubmit = () => {};
383
+ let onExit = () => {};
384
+ let prompt = "cofos";
385
+ let slashHintShown = false;
386
+
387
+ function setPrompt(label) {
388
+ prompt = label;
389
+ rl.setPrompt(`${C}${prompt}${R} `);
390
+ }
391
+
392
+ setPrompt(prompt);
393
+ if (process.stdin.isTTY) {
394
+ emitKeypressEvents(process.stdin, rl);
395
+ process.stdin.on("keypress", () => {
396
+ setTimeout(() => {
397
+ const line = rl.line || "";
398
+ if (line === "/" && !slashHintShown) {
399
+ slashHintShown = true;
400
+ process.stdout.write(`\n${slashInlineHint("/")}\n`);
401
+ rl.prompt(true);
402
+ } else if (!line.startsWith("/")) {
403
+ slashHintShown = false;
404
+ }
405
+ }, 0);
406
+ });
407
+ }
408
+ rl.on("line", (line) => {
409
+ slashHintShown = false;
410
+ onSubmit(line);
411
+ });
412
+ rl.on("SIGINT", () => onExit());
413
+ rl.on("close", () => onExit());
414
+
415
+ return {
416
+ supportsReplace: false,
417
+ append: (text = "") => process.stdout.write(blessedToAnsi(text)),
418
+ print: (text = "") => console.log(blessedToAnsi(text)),
419
+ clear: () => console.clear(),
420
+ length: () => 0,
421
+ replaceFrom: (_index, text = "") => process.stdout.write(blessedToAnsi(text)),
422
+ setSubmitHandler: (fn) => { onSubmit = fn; },
423
+ setExitHandler: (fn) => { onExit = fn; },
424
+ focus: () => rl.prompt(),
425
+ setPrompt,
426
+ destroy: () => { try { rl.close(); } catch (_) {} },
339
427
  };
340
428
  }
341
429
 
342
430
  /* ─── main ─── */
343
431
 
344
- function main() {
432
+ async function main() {
345
433
  if (process.argv.includes("-h") || process.argv.includes("--help")) {
346
434
  console.log("\n COFOS CLI — 9B + RAG chat (Willlzh/COFOS)\n");
347
- console.log(" Usage: cofos [--model PATH] [--config PATH] [--pdf-dir DIR] [--cache-dir DIR] [--top-k N] [--no-rag]\n");
435
+ console.log(" Usage: cofos [--tui] [-p QUESTION] [--model PATH] [--config PATH] [--pdf-dir DIR] [--cache-dir DIR] [--top-k N] [--no-rag]\n");
348
436
  console.log(" Commands in chat:");
349
437
  console.log(" /help Show this help");
350
438
  console.log(" /exit Quit");
@@ -356,6 +444,9 @@ function main() {
356
444
  console.log(" Startup options:");
357
445
  console.log(" --pdf-dir DIR Add PDFs from DIR as extra BM25 evidence");
358
446
  console.log(" --cache-dir DIR Store model/data cache here (default: ./.cofos)");
447
+ console.log(" --max-new-tokens N Override generation length");
448
+ console.log(" --tui Use the experimental fixed-input TUI");
449
+ console.log(" -p, --prompt TEXT Ask once and exit");
359
450
  console.log(" --rebuild-pdf-index Force reparsing PDFs and rebuilding BM25\n");
360
451
  console.log(" Multi-line: end a line with \\ to continue on the next line.\n");
361
452
  process.exit(0);
@@ -368,8 +459,11 @@ function main() {
368
459
  let configPath = DEFAULT_CONFIG;
369
460
  let topK = "5";
370
461
  let pdfDir = null;
462
+ let maxNewTokens = null;
371
463
  let cacheDir = process.env.COFOS_CACHE_DIR ? resolve(process.env.COFOS_CACHE_DIR) : resolve(process.cwd(), ".cofos");
372
464
  let rebuildPdfIndex = false;
465
+ let useTui = false;
466
+ let singlePrompt = null;
373
467
  const backendArgs = [chatScript];
374
468
 
375
469
  const die = (msg) => { console.error("[cofos] " + msg); process.exit(1); };
@@ -393,10 +487,17 @@ function main() {
393
487
  } else if (arg === "--pdf-dir") {
394
488
  pdfDir = resolve(value(++i, arg));
395
489
  if (!existsSync(pdfDir)) die(`--pdf-dir does not exist: ${pdfDir}`);
490
+ } else if (arg === "--max-new-tokens") {
491
+ maxNewTokens = value(++i, arg);
492
+ if (!/^\d+$/.test(maxNewTokens) || Number(maxNewTokens) < 1) die(`--max-new-tokens must be a positive integer, got "${maxNewTokens}"`);
396
493
  } else if (arg === "--cache-dir") {
397
494
  cacheDir = resolve(value(++i, arg));
398
495
  } else if (arg === "--rebuild-pdf-index") {
399
496
  rebuildPdfIndex = true;
497
+ } else if (arg === "--tui") {
498
+ useTui = true;
499
+ } else if (arg === "-p" || arg === "--prompt") {
500
+ singlePrompt = value(++i, arg);
400
501
  } else if (arg === "--no-rag") {
401
502
  backendArgs.push("--no-rag");
402
503
  } else {
@@ -405,14 +506,15 @@ function main() {
405
506
  }
406
507
  backendArgs.push("--merged-model-path", modelPath, "--config", configPath, "--top-k", topK);
407
508
  if (pdfDir) backendArgs.push("--pdf-dir", pdfDir);
509
+ if (maxNewTokens) backendArgs.push("--max-new-tokens", maxNewTokens);
408
510
  if (rebuildPdfIndex) backendArgs.push("--rebuild-pdf-index");
409
511
 
410
512
  const py = findPy();
411
513
  if (!py) die("Python not found. Install Python 3.9+ and re-run.");
412
514
  if (!existsSync(chatScript)) die(`Missing script: ${chatScript}`);
413
515
 
414
- const ui = createTui();
415
- ui.print(plain(render({ modelPath, cacheDir, pdfDir })));
516
+ const ui = useTui ? await createTui() : createPlainUi();
517
+ ui.print(useTui ? plain(render({ modelPath, cacheDir, pdfDir })) : render({ modelPath, cacheDir, pdfDir }));
416
518
  ensureDeps(py, (msg) => ui.print(msg));
417
519
  ui.print(" Loading COFOS 9B + RAG backend from Hugging Face … first run may take a while");
418
520
  if (pdfDir) ui.print(" Local PDF RAG: enabled for this session");
@@ -439,7 +541,8 @@ function main() {
439
541
  proc.stderr.setEncoding("utf-8");
440
542
 
441
543
  let buf = "", ready = false, generating = false, responseOpen = false, commandOpen = false, closed = false;
442
- let responseMaybeReasoning = false, responseReasoning = false, responseBuffer = "";
544
+ let currentAnswer = "", assistantStart = -1, plainAssistantMode = "undecided", plainAssistantPending = "";
545
+ let plainReasonBuffer = "", plainReasonPrinted = 0;
443
546
  const inputQ = [];
444
547
  let mlBuf = [];
445
548
 
@@ -449,6 +552,7 @@ function main() {
449
552
  try { proc.stdin.end(); } catch (_) {}
450
553
  try { proc.kill("SIGTERM"); } catch (_) {}
451
554
  ui.destroy();
555
+ process.exit(0);
452
556
  }
453
557
 
454
558
  ui.setExitHandler(shutdown);
@@ -479,7 +583,7 @@ function main() {
479
583
  input = input.trim();
480
584
  if (!input) return false;
481
585
 
482
- if (/^\/(exit|quit)$/.test(input)) { proc.stdin.write("/exit\n"); proc.stdin.end(); return true; }
586
+ if (/^\/(exit|quit)$/.test(input)) { shutdown(); return true; }
483
587
  if (/^\/(help)$/.test(input)) {
484
588
  ui.print(slashHint("/") + "\n Add local papers to RAG:\n cofos --pdf-dir ./new_pdfs\n cofos --pdf-dir ./new_pdfs --rebuild-pdf-index\n\n Multi-line: end a line with \\ to continue.");
485
589
  return false;
@@ -492,13 +596,25 @@ function main() {
492
596
  ui.clear();
493
597
  mlBuf = [];
494
598
  responseOpen = false;
599
+ assistantStart = -1;
600
+ currentAnswer = "";
601
+ plainAssistantMode = "undecided";
602
+ plainAssistantPending = "";
603
+ plainReasonBuffer = "";
604
+ plainReasonPrinted = 0;
495
605
  commandOpen = true;
496
606
  proc.stdin.write(input + "\n");
497
607
  generating = true;
498
608
  return true;
499
609
  }
500
- if (/^\/(topk)(?:\s+\d+)?$/.test(input) || /^\/rag\s+(?:on|off)$/.test(input)) {
610
+ if (/^\/(save)$/.test(input) || /^\/(topk)(?:\s+\d+)?$/.test(input) || /^\/rag\s+(?:on|off)$/.test(input)) {
501
611
  responseOpen = false;
612
+ assistantStart = -1;
613
+ currentAnswer = "";
614
+ plainAssistantMode = "undecided";
615
+ plainAssistantPending = "";
616
+ plainReasonBuffer = "";
617
+ plainReasonPrinted = 0;
502
618
  commandOpen = true;
503
619
  proc.stdin.write(input + "\n");
504
620
  generating = true;
@@ -515,6 +631,12 @@ function main() {
515
631
 
516
632
  ui.print("{yellow-fg}●{/yellow-fg} " + input);
517
633
  responseOpen = false;
634
+ assistantStart = -1;
635
+ currentAnswer = "";
636
+ plainAssistantMode = "undecided";
637
+ plainAssistantPending = "";
638
+ plainReasonBuffer = "";
639
+ plainReasonPrinted = 0;
518
640
  generating = true;
519
641
  proc.stdin.write(input + "\n");
520
642
  return true;
@@ -527,39 +649,104 @@ function main() {
527
649
 
528
650
  proc.stderr.on("data", (chunk) => ui.append(chunk));
529
651
 
530
- function appendAssistant(text) {
531
- if (!text) return;
532
- if (responseMaybeReasoning) {
533
- responseBuffer += text;
534
- const enough = responseBuffer.length > 80 || responseBuffer.includes("\n\n") || responseBuffer.includes("。") || responseBuffer.includes(".");
535
- if (!enough) return;
536
- const start = responseBuffer.trimStart();
537
- responseReasoning = /^(The user|Okay[,,]|I need|We need|Based on the provided information[,,]? I can|用户)/i.test(start);
538
- responseMaybeReasoning = false;
539
- if (responseReasoning) {
540
- ui.append("{gray-fg}- " + responseBuffer + "{/gray-fg}");
652
+ function escapeTags(text) {
653
+ return String(text).replace(/[{}]/g, "");
654
+ }
655
+
656
+ function formatAssistant(answer) {
657
+ const raw = escapeTags(answer).trimStart();
658
+ if (!raw) return "";
659
+ if (raw.includes("</think>")) {
660
+ const [reason, ...answerParts] = raw.split("</think>");
661
+ const cleanReason = reason.replace(/^<think>\s*/i, "").trim();
662
+ const finalAnswer = answerParts.join("</think>").trimStart();
663
+ return finalAnswer
664
+ ? "{gray-fg}- " + cleanReason + "{/gray-fg}\n{green-fg}{bold}◆{/bold}{/green-fg} " + finalAnswer
665
+ : "{gray-fg}- " + cleanReason + "{/gray-fg}";
666
+ }
667
+
668
+ const paragraphs = raw.split(/\n\s*\n/);
669
+ const reasonRe = /^(The user|Okay[,,]|I need|We need|I should|I'll|Let me|Since the input|In COF nomenclature|Based on the provided information[,,]? I can|用户|让我)/i;
670
+ let reasonCount = 0;
671
+ while (reasonCount < paragraphs.length && reasonRe.test(paragraphs[reasonCount].trim())) reasonCount += 1;
672
+ if (reasonCount > 0) {
673
+ const reason = paragraphs.slice(0, reasonCount).join("\n\n").trim();
674
+ const finalAnswer = paragraphs.slice(reasonCount).join("\n\n").trimStart();
675
+ return finalAnswer
676
+ ? "{gray-fg}- " + reason + "{/gray-fg}\n{green-fg}{bold}◆{/bold}{/green-fg} " + finalAnswer
677
+ : "{gray-fg}- " + reason + "{/gray-fg}";
678
+ }
679
+ return "{green-fg}{bold}◆{/bold}{/green-fg} " + raw;
680
+ }
681
+
682
+ const reasonRe = /^(The user|Okay[,,]|I need|We need|I should|I'll|Let me|Since the input|In COF nomenclature|Based on the provided information[,,]? I can|用户|让我)/i;
683
+
684
+ function redrawAssistant() {
685
+ if (assistantStart < 0 || !ui.supportsReplace) return;
686
+ ui.replaceFrom(assistantStart, formatAssistant(currentAnswer));
687
+ }
688
+
689
+ function streamPlainAssistant(text, final = false) {
690
+ if (ui.supportsReplace) return;
691
+ plainAssistantPending += text;
692
+ if (plainAssistantMode === "undecided") {
693
+ const start = plainAssistantPending.trimStart();
694
+ if (!start && !final) return;
695
+ if (start.startsWith("<think>") || reasonRe.test(start)) {
696
+ plainAssistantMode = "reason";
697
+ plainReasonBuffer = start.replace(/^<think>\s*/i, "");
698
+ plainReasonPrinted = 0;
699
+ plainAssistantPending = "";
700
+ ui.append(`${G}-${R} ${G}`);
701
+ } else if (final || start.length >= 12 || /\s/.test(start)) {
702
+ plainAssistantMode = "answer";
703
+ plainAssistantPending = start;
704
+ ui.append(`${GR}${B}◆${R} `);
541
705
  } else {
542
- ui.append(responseBuffer);
706
+ return;
543
707
  }
544
- responseBuffer = "";
545
- return;
546
708
  }
547
- if (responseReasoning) {
548
- const end = text.indexOf("\n\n");
549
- if (end !== -1) {
550
- const reason = text.slice(0, end);
551
- const answer = text.slice(end).replace(/^\s+/, "");
552
- if (reason) ui.append("{gray-fg}" + reason + "{/gray-fg}");
553
- responseReasoning = false;
554
- if (answer) ui.append("\n{green-fg}{bold}◆{/bold}{/green-fg} " + answer);
709
+
710
+ if (plainAssistantMode === "reason") {
711
+ plainReasonBuffer += plainAssistantPending;
712
+ plainAssistantPending = "";
713
+ const thinkEnd = plainReasonBuffer.indexOf("</think>");
714
+ const paraEnd = plainReasonBuffer.search(/\n\s*\n/);
715
+ const split = thinkEnd !== -1 ? thinkEnd : paraEnd;
716
+ if (split !== -1) {
717
+ const reason = plainReasonBuffer.slice(plainReasonPrinted, split).replace(/<\/think>/gi, "");
718
+ const restStart = thinkEnd !== -1 ? split + "</think>".length : split;
719
+ const rest = plainReasonBuffer.slice(restStart).trimStart();
720
+ if (reason) ui.append(reason);
721
+ ui.append(`${R}\n${GR}${B}◆${R} `);
722
+ plainAssistantMode = "answer";
723
+ plainAssistantPending = rest;
724
+ plainReasonBuffer = "";
725
+ plainReasonPrinted = 0;
555
726
  } else {
556
- ui.append("{gray-fg}" + text + "{/gray-fg}");
727
+ const printableUntil = final ? plainReasonBuffer.length : Math.max(0, plainReasonBuffer.length - 2);
728
+ if (printableUntil > plainReasonPrinted) {
729
+ ui.append(plainReasonBuffer.slice(plainReasonPrinted, printableUntil));
730
+ plainReasonPrinted = printableUntil;
731
+ }
732
+ if (!final) return;
733
+ if (plainReasonPrinted < plainReasonBuffer.length) ui.append(plainReasonBuffer.slice(plainReasonPrinted));
734
+ plainReasonBuffer = "";
735
+ plainReasonPrinted = 0;
736
+ ui.append(R);
737
+ return;
738
+ }
739
+ }
740
+
741
+ if (plainAssistantMode === "answer") {
742
+ if (plainAssistantPending) {
743
+ ui.append(plainAssistantPending);
744
+ plainAssistantPending = "";
557
745
  }
558
- return;
559
746
  }
560
- ui.append(text);
561
747
  }
562
748
 
749
+
563
750
  proc.stdout.on("data", (chunk) => {
564
751
  buf += chunk;
565
752
  if (!ready) {
@@ -568,6 +755,8 @@ function main() {
568
755
  ready = true;
569
756
  buf = buf.slice(i + READY.length);
570
757
  if (buf[0] === "\n") buf = buf.slice(1);
758
+ if (singlePrompt) inputQ.push(singlePrompt);
759
+ else ui.focus();
571
760
  drain();
572
761
  }
573
762
  return;
@@ -583,7 +772,8 @@ function main() {
583
772
  if (buf[0] === "\n") buf = buf.slice(1);
584
773
  generating = false;
585
774
  commandOpen = false;
586
- ui.focus();
775
+ if (singlePrompt && inputQ.length === 0) shutdown();
776
+ else ui.focus();
587
777
  drain();
588
778
  return;
589
779
  }
@@ -598,29 +788,33 @@ function main() {
598
788
  buf = buf.slice(ri + RESPONSE.length);
599
789
  if (buf[0] === "\n") buf = buf.slice(1);
600
790
  responseOpen = true;
601
- responseMaybeReasoning = true;
602
- responseReasoning = false;
603
- responseBuffer = "";
604
- ui.append("{green-fg}{bold}◆{/bold}{/green-fg} ");
791
+ assistantStart = ui.supportsReplace ? ui.length() : 0;
792
+ currentAnswer = "";
793
+ plainAssistantMode = "undecided";
794
+ plainAssistantPending = "";
795
+ plainReasonBuffer = "";
796
+ plainReasonPrinted = 0;
605
797
  }
606
798
 
607
799
  const di = buf.indexOf(DONE);
608
800
  if (di !== -1) {
609
801
  const pre = buf.slice(0, di);
610
- if (pre) appendAssistant(pre);
802
+ if (pre) { currentAnswer += pre; if (ui.supportsReplace) redrawAssistant(); else streamPlainAssistant(pre); }
611
803
  buf = buf.slice(di + DONE.length);
612
804
  if (buf[0] === "\n") buf = buf.slice(1);
613
- if (responseBuffer) { appendAssistant("\n"); }
805
+ if (ui.supportsReplace) redrawAssistant();
806
+ else streamPlainAssistant("", true);
614
807
  generating = false;
615
808
  responseOpen = false;
616
- ui.append("\n");
617
- ui.focus();
809
+ if (ui.supportsReplace) ui.append("\n");
810
+ if (singlePrompt && inputQ.length === 0) shutdown();
811
+ else ui.focus();
618
812
  drain();
619
813
  } else {
620
814
  const keep = DONE.length - 1;
621
815
  if (buf.length > keep) {
622
816
  const out = buf.slice(0, -keep);
623
- if (out) appendAssistant(out);
817
+ if (out) { currentAnswer += out; if (ui.supportsReplace) redrawAssistant(); else streamPlainAssistant(out); }
624
818
  buf = buf.slice(-keep);
625
819
  }
626
820
  }
@@ -628,12 +822,18 @@ function main() {
628
822
 
629
823
  proc.on("error", (e) => { ui.print("[cofos] Python error: " + e.message); shutdown(); });
630
824
  proc.on("exit", (c) => {
631
- closed = true;
632
- if (c && c !== 0) ui.print("[cofos] Python exited with code " + c);
633
- ui.destroy();
825
+ if (closed) return;
826
+ ui.print(c && c !== 0 ? "[cofos] Python exited with code " + c : "[cofos] Python backend exited.");
827
+ ui.print("Press Ctrl+C to close.");
828
+ ui.focus();
634
829
  process.exitCode = c ?? 0;
635
830
  });
636
831
 
637
832
  }
638
833
 
834
+ process.on("uncaughtException", (err) => {
835
+ try { process.stderr.write(`[cofos] Uncaught error: ${err?.stack || err}\n`); } catch (_) {}
836
+ process.exit(1);
837
+ });
838
+
639
839
  main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lzhzzzzwill/cofos",
3
- "version": "1.2.4",
3
+ "version": "1.3.1",
4
4
  "description": "COFOS 9B + RAG chat CLI with optional local PDF ingestion.",
5
5
  "type": "module",
6
6
  "bin": {