@lzhzzzzwill/cofos 1.2.4 → 1.3.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/README.md +16 -0
  2. package/bin/cofos.js +147 -57
  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 } 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";
@@ -154,12 +154,15 @@ function ensureDeps(py, log = console.log) {
154
154
 
155
155
  /* ─── TUI ─── */
156
156
 
157
- function createTui() {
157
+ async function createTui() {
158
+ const { default: blessed } = await import("blessed");
158
159
  const screen = blessed.screen({
159
160
  smartCSR: true,
160
161
  fullUnicode: true,
161
162
  title: "COFOS",
163
+ mouse: false,
162
164
  });
165
+ screen.program.disableMouse();
163
166
 
164
167
  const history = blessed.box({
165
168
  top: 0,
@@ -170,7 +173,7 @@ function createTui() {
170
173
  alwaysScroll: true,
171
174
  keys: true,
172
175
  vi: true,
173
- mouse: true,
176
+ mouse: false,
174
177
  tags: true,
175
178
  scrollbar: { ch: " ", track: { bg: "black" }, style: { bg: "cyan" } },
176
179
  });
@@ -213,6 +216,12 @@ function createTui() {
213
216
  let suggestionMatches = [];
214
217
  let selectedSuggestion = 0;
215
218
 
219
+ function renderBuffer(wasAtBottom = true) {
220
+ history.setContent(buffer);
221
+ if (wasAtBottom) history.setScrollPerc(100);
222
+ screen.render();
223
+ }
224
+
216
225
  function append(text = "") {
217
226
  const wasAtBottom = history.getScrollPerc() >= 98;
218
227
  const data = String(text).replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
@@ -226,9 +235,7 @@ function createTui() {
226
235
  }
227
236
  }
228
237
  if (buffer.length > 250000) buffer = buffer.slice(-220000);
229
- history.setContent(buffer);
230
- if (wasAtBottom) history.setScrollPerc(100);
231
- screen.render();
238
+ renderBuffer(wasAtBottom);
232
239
  }
233
240
 
234
241
  function hideSuggestions() {
@@ -326,25 +333,75 @@ function createTui() {
326
333
  screen.render();
327
334
 
328
335
  return {
336
+ supportsReplace: true,
329
337
  screen,
330
338
  input,
331
339
  append,
332
340
  print: (text = "") => append(String(text) + "\n"),
333
- clear: () => { buffer = ""; history.setContent(""); history.setScrollPerc(100); screen.render(); },
341
+ clear: () => { buffer = ""; renderBuffer(true); },
342
+ length: () => buffer.length,
343
+ replaceFrom: (index, text = "") => { buffer = buffer.slice(0, Math.max(0, index)) + String(text); renderBuffer(true); },
334
344
  setSubmitHandler: (fn) => { onSubmit = fn; },
335
345
  setExitHandler: (fn) => { onExit = fn; },
336
346
  focus: () => { input.focus(); screen.render(); },
337
347
  setPrompt: (label) => { input.setLabel(` ${label} `); screen.render(); },
338
- destroy: () => screen.destroy(),
348
+ destroy: () => {
349
+ try { screen.program.disableMouse(); } catch (_) {}
350
+ screen.destroy();
351
+ },
352
+ };
353
+ }
354
+
355
+ function stripBlessedTags(text) {
356
+ return String(text)
357
+ .replace(/\{\/?(?:bold|cyan-fg|yellow-fg|green-fg|gray-fg|red-fg|black-fg|yellow-bg)\}/g, "")
358
+ .replace(/[{}]/g, "");
359
+ }
360
+
361
+ function createPlainUi() {
362
+ const rl = createInterface({
363
+ input: process.stdin,
364
+ output: process.stdout,
365
+ terminal: true,
366
+ completer: (line) => slashCompletions(line),
367
+ });
368
+ let onSubmit = () => {};
369
+ let onExit = () => {};
370
+ let prompt = "cofos";
371
+
372
+ function setPrompt(label) {
373
+ prompt = label;
374
+ rl.setPrompt(`${C}${prompt}${R} `);
375
+ }
376
+
377
+ setPrompt(prompt);
378
+ rl.on("line", (line) => {
379
+ onSubmit(line);
380
+ });
381
+ rl.on("SIGINT", () => onExit());
382
+ rl.on("close", () => onExit());
383
+
384
+ return {
385
+ supportsReplace: false,
386
+ append: (text = "") => process.stdout.write(stripBlessedTags(text)),
387
+ print: (text = "") => console.log(stripBlessedTags(text)),
388
+ clear: () => console.clear(),
389
+ length: () => 0,
390
+ replaceFrom: (_index, text = "") => process.stdout.write(stripBlessedTags(text)),
391
+ setSubmitHandler: (fn) => { onSubmit = fn; },
392
+ setExitHandler: (fn) => { onExit = fn; },
393
+ focus: () => rl.prompt(),
394
+ setPrompt,
395
+ destroy: () => { try { rl.close(); } catch (_) {} },
339
396
  };
340
397
  }
341
398
 
342
399
  /* ─── main ─── */
343
400
 
344
- function main() {
401
+ async function main() {
345
402
  if (process.argv.includes("-h") || process.argv.includes("--help")) {
346
403
  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");
404
+ console.log(" Usage: cofos [--tui] [-p QUESTION] [--model PATH] [--config PATH] [--pdf-dir DIR] [--cache-dir DIR] [--top-k N] [--no-rag]\n");
348
405
  console.log(" Commands in chat:");
349
406
  console.log(" /help Show this help");
350
407
  console.log(" /exit Quit");
@@ -356,6 +413,9 @@ function main() {
356
413
  console.log(" Startup options:");
357
414
  console.log(" --pdf-dir DIR Add PDFs from DIR as extra BM25 evidence");
358
415
  console.log(" --cache-dir DIR Store model/data cache here (default: ./.cofos)");
416
+ console.log(" --max-new-tokens N Override generation length");
417
+ console.log(" --tui Use the experimental fixed-input TUI");
418
+ console.log(" -p, --prompt TEXT Ask once and exit");
359
419
  console.log(" --rebuild-pdf-index Force reparsing PDFs and rebuilding BM25\n");
360
420
  console.log(" Multi-line: end a line with \\ to continue on the next line.\n");
361
421
  process.exit(0);
@@ -368,8 +428,11 @@ function main() {
368
428
  let configPath = DEFAULT_CONFIG;
369
429
  let topK = "5";
370
430
  let pdfDir = null;
431
+ let maxNewTokens = null;
371
432
  let cacheDir = process.env.COFOS_CACHE_DIR ? resolve(process.env.COFOS_CACHE_DIR) : resolve(process.cwd(), ".cofos");
372
433
  let rebuildPdfIndex = false;
434
+ let useTui = false;
435
+ let singlePrompt = null;
373
436
  const backendArgs = [chatScript];
374
437
 
375
438
  const die = (msg) => { console.error("[cofos] " + msg); process.exit(1); };
@@ -393,10 +456,17 @@ function main() {
393
456
  } else if (arg === "--pdf-dir") {
394
457
  pdfDir = resolve(value(++i, arg));
395
458
  if (!existsSync(pdfDir)) die(`--pdf-dir does not exist: ${pdfDir}`);
459
+ } else if (arg === "--max-new-tokens") {
460
+ maxNewTokens = value(++i, arg);
461
+ if (!/^\d+$/.test(maxNewTokens) || Number(maxNewTokens) < 1) die(`--max-new-tokens must be a positive integer, got "${maxNewTokens}"`);
396
462
  } else if (arg === "--cache-dir") {
397
463
  cacheDir = resolve(value(++i, arg));
398
464
  } else if (arg === "--rebuild-pdf-index") {
399
465
  rebuildPdfIndex = true;
466
+ } else if (arg === "--tui") {
467
+ useTui = true;
468
+ } else if (arg === "-p" || arg === "--prompt") {
469
+ singlePrompt = value(++i, arg);
400
470
  } else if (arg === "--no-rag") {
401
471
  backendArgs.push("--no-rag");
402
472
  } else {
@@ -405,14 +475,15 @@ function main() {
405
475
  }
406
476
  backendArgs.push("--merged-model-path", modelPath, "--config", configPath, "--top-k", topK);
407
477
  if (pdfDir) backendArgs.push("--pdf-dir", pdfDir);
478
+ if (maxNewTokens) backendArgs.push("--max-new-tokens", maxNewTokens);
408
479
  if (rebuildPdfIndex) backendArgs.push("--rebuild-pdf-index");
409
480
 
410
481
  const py = findPy();
411
482
  if (!py) die("Python not found. Install Python 3.9+ and re-run.");
412
483
  if (!existsSync(chatScript)) die(`Missing script: ${chatScript}`);
413
484
 
414
- const ui = createTui();
415
- ui.print(plain(render({ modelPath, cacheDir, pdfDir })));
485
+ const ui = useTui ? await createTui() : createPlainUi();
486
+ ui.print(useTui ? plain(render({ modelPath, cacheDir, pdfDir })) : render({ modelPath, cacheDir, pdfDir }));
416
487
  ensureDeps(py, (msg) => ui.print(msg));
417
488
  ui.print(" Loading COFOS 9B + RAG backend from Hugging Face … first run may take a while");
418
489
  if (pdfDir) ui.print(" Local PDF RAG: enabled for this session");
@@ -439,7 +510,7 @@ function main() {
439
510
  proc.stderr.setEncoding("utf-8");
440
511
 
441
512
  let buf = "", ready = false, generating = false, responseOpen = false, commandOpen = false, closed = false;
442
- let responseMaybeReasoning = false, responseReasoning = false, responseBuffer = "";
513
+ let currentAnswer = "", assistantStart = -1;
443
514
  const inputQ = [];
444
515
  let mlBuf = [];
445
516
 
@@ -449,6 +520,7 @@ function main() {
449
520
  try { proc.stdin.end(); } catch (_) {}
450
521
  try { proc.kill("SIGTERM"); } catch (_) {}
451
522
  ui.destroy();
523
+ process.exit(0);
452
524
  }
453
525
 
454
526
  ui.setExitHandler(shutdown);
@@ -479,7 +551,7 @@ function main() {
479
551
  input = input.trim();
480
552
  if (!input) return false;
481
553
 
482
- if (/^\/(exit|quit)$/.test(input)) { proc.stdin.write("/exit\n"); proc.stdin.end(); return true; }
554
+ if (/^\/(exit|quit)$/.test(input)) { shutdown(); return true; }
483
555
  if (/^\/(help)$/.test(input)) {
484
556
  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
557
  return false;
@@ -492,13 +564,17 @@ function main() {
492
564
  ui.clear();
493
565
  mlBuf = [];
494
566
  responseOpen = false;
567
+ assistantStart = -1;
568
+ currentAnswer = "";
495
569
  commandOpen = true;
496
570
  proc.stdin.write(input + "\n");
497
571
  generating = true;
498
572
  return true;
499
573
  }
500
- if (/^\/(topk)(?:\s+\d+)?$/.test(input) || /^\/rag\s+(?:on|off)$/.test(input)) {
574
+ if (/^\/(save)$/.test(input) || /^\/(topk)(?:\s+\d+)?$/.test(input) || /^\/rag\s+(?:on|off)$/.test(input)) {
501
575
  responseOpen = false;
576
+ assistantStart = -1;
577
+ currentAnswer = "";
502
578
  commandOpen = true;
503
579
  proc.stdin.write(input + "\n");
504
580
  generating = true;
@@ -515,6 +591,8 @@ function main() {
515
591
 
516
592
  ui.print("{yellow-fg}●{/yellow-fg} " + input);
517
593
  responseOpen = false;
594
+ assistantStart = -1;
595
+ currentAnswer = "";
518
596
  generating = true;
519
597
  proc.stdin.write(input + "\n");
520
598
  return true;
@@ -527,39 +605,42 @@ function main() {
527
605
 
528
606
  proc.stderr.on("data", (chunk) => ui.append(chunk));
529
607
 
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}");
541
- } else {
542
- ui.append(responseBuffer);
543
- }
544
- responseBuffer = "";
545
- return;
608
+ function escapeTags(text) {
609
+ return String(text).replace(/[{}]/g, "");
610
+ }
611
+
612
+ function formatAssistant(answer) {
613
+ const raw = escapeTags(answer).trimStart();
614
+ if (!raw) return "";
615
+ if (raw.includes("</think>")) {
616
+ const [reason, ...answerParts] = raw.split("</think>");
617
+ const cleanReason = reason.replace(/^<think>\s*/i, "").trim();
618
+ const finalAnswer = answerParts.join("</think>").trimStart();
619
+ return finalAnswer
620
+ ? "{gray-fg}- " + cleanReason + "{/gray-fg}\n{green-fg}{bold}◆{/bold}{/green-fg} " + finalAnswer
621
+ : "{gray-fg}- " + cleanReason + "{/gray-fg}";
546
622
  }
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);
555
- } else {
556
- ui.append("{gray-fg}" + text + "{/gray-fg}");
557
- }
558
- return;
623
+
624
+ const paragraphs = raw.split(/\n\s*\n/);
625
+ 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;
626
+ let reasonCount = 0;
627
+ while (reasonCount < paragraphs.length && reasonRe.test(paragraphs[reasonCount].trim())) reasonCount += 1;
628
+ if (reasonCount > 0) {
629
+ const reason = paragraphs.slice(0, reasonCount).join("\n\n").trim();
630
+ const finalAnswer = paragraphs.slice(reasonCount).join("\n\n").trimStart();
631
+ return finalAnswer
632
+ ? "{gray-fg}- " + reason + "{/gray-fg}\n{green-fg}{bold}◆{/bold}{/green-fg} " + finalAnswer
633
+ : "{gray-fg}- " + reason + "{/gray-fg}";
559
634
  }
560
- ui.append(text);
635
+ return "{green-fg}{bold}◆{/bold}{/green-fg} " + raw;
636
+ }
637
+
638
+ function redrawAssistant() {
639
+ if (assistantStart < 0 || !ui.supportsReplace) return;
640
+ ui.replaceFrom(assistantStart, formatAssistant(currentAnswer));
561
641
  }
562
642
 
643
+
563
644
  proc.stdout.on("data", (chunk) => {
564
645
  buf += chunk;
565
646
  if (!ready) {
@@ -568,6 +649,8 @@ function main() {
568
649
  ready = true;
569
650
  buf = buf.slice(i + READY.length);
570
651
  if (buf[0] === "\n") buf = buf.slice(1);
652
+ if (singlePrompt) inputQ.push(singlePrompt);
653
+ else ui.focus();
571
654
  drain();
572
655
  }
573
656
  return;
@@ -583,7 +666,8 @@ function main() {
583
666
  if (buf[0] === "\n") buf = buf.slice(1);
584
667
  generating = false;
585
668
  commandOpen = false;
586
- ui.focus();
669
+ if (singlePrompt && inputQ.length === 0) shutdown();
670
+ else ui.focus();
587
671
  drain();
588
672
  return;
589
673
  }
@@ -598,29 +682,29 @@ function main() {
598
682
  buf = buf.slice(ri + RESPONSE.length);
599
683
  if (buf[0] === "\n") buf = buf.slice(1);
600
684
  responseOpen = true;
601
- responseMaybeReasoning = true;
602
- responseReasoning = false;
603
- responseBuffer = "";
604
- ui.append("{green-fg}{bold}◆{/bold}{/green-fg} ");
685
+ assistantStart = ui.supportsReplace ? ui.length() : 0;
686
+ currentAnswer = "";
605
687
  }
606
688
 
607
689
  const di = buf.indexOf(DONE);
608
690
  if (di !== -1) {
609
691
  const pre = buf.slice(0, di);
610
- if (pre) appendAssistant(pre);
692
+ if (pre) { currentAnswer += pre; redrawAssistant(); }
611
693
  buf = buf.slice(di + DONE.length);
612
694
  if (buf[0] === "\n") buf = buf.slice(1);
613
- if (responseBuffer) { appendAssistant("\n"); }
695
+ if (ui.supportsReplace) redrawAssistant();
696
+ else ui.print(formatAssistant(currentAnswer));
614
697
  generating = false;
615
698
  responseOpen = false;
616
- ui.append("\n");
617
- ui.focus();
699
+ if (ui.supportsReplace) ui.append("\n");
700
+ if (singlePrompt && inputQ.length === 0) shutdown();
701
+ else ui.focus();
618
702
  drain();
619
703
  } else {
620
704
  const keep = DONE.length - 1;
621
705
  if (buf.length > keep) {
622
706
  const out = buf.slice(0, -keep);
623
- if (out) appendAssistant(out);
707
+ if (out) { currentAnswer += out; redrawAssistant(); }
624
708
  buf = buf.slice(-keep);
625
709
  }
626
710
  }
@@ -628,12 +712,18 @@ function main() {
628
712
 
629
713
  proc.on("error", (e) => { ui.print("[cofos] Python error: " + e.message); shutdown(); });
630
714
  proc.on("exit", (c) => {
631
- closed = true;
632
- if (c && c !== 0) ui.print("[cofos] Python exited with code " + c);
633
- ui.destroy();
715
+ if (closed) return;
716
+ ui.print(c && c !== 0 ? "[cofos] Python exited with code " + c : "[cofos] Python backend exited.");
717
+ ui.print("Press Ctrl+C to close.");
718
+ ui.focus();
634
719
  process.exitCode = c ?? 0;
635
720
  });
636
721
 
637
722
  }
638
723
 
724
+ process.on("uncaughtException", (err) => {
725
+ try { process.stderr.write(`[cofos] Uncaught error: ${err?.stack || err}\n`); } catch (_) {}
726
+ process.exit(1);
727
+ });
728
+
639
729
  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.0",
4
4
  "description": "COFOS 9B + RAG chat CLI with optional local PDF ingestion.",
5
5
  "type": "module",
6
6
  "bin": {