@lzhzzzzwill/cofos 1.2.3 → 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 +169 -25
  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";
@@ -106,7 +106,8 @@ function render({ modelPath, cacheDir, pdfDir }) {
106
106
  const SLASH_COMMANDS = [
107
107
  { cmd: "/help", desc: "Show help" },
108
108
  { cmd: "/exit", desc: "Quit" },
109
- { cmd: "/clear", desc: "Clear conversation history" },
109
+ { cmd: "/clear", desc: "Clear page and conversation history" },
110
+ { cmd: "/cls", desc: "Clear page only" },
110
111
  { cmd: "/save", desc: "Save this session history" },
111
112
  { cmd: "/info", desc: "Show model and RAG info" },
112
113
  { cmd: "/topk 5", desc: "Set retrieval top-k" },
@@ -153,12 +154,15 @@ function ensureDeps(py, log = console.log) {
153
154
 
154
155
  /* ─── TUI ─── */
155
156
 
156
- function createTui() {
157
+ async function createTui() {
158
+ const { default: blessed } = await import("blessed");
157
159
  const screen = blessed.screen({
158
160
  smartCSR: true,
159
161
  fullUnicode: true,
160
162
  title: "COFOS",
163
+ mouse: false,
161
164
  });
165
+ screen.program.disableMouse();
162
166
 
163
167
  const history = blessed.box({
164
168
  top: 0,
@@ -170,7 +174,7 @@ function createTui() {
170
174
  keys: true,
171
175
  vi: true,
172
176
  mouse: false,
173
- tags: false,
177
+ tags: true,
174
178
  scrollbar: { ch: " ", track: { bg: "black" }, style: { bg: "cyan" } },
175
179
  });
176
180
 
@@ -212,7 +216,14 @@ function createTui() {
212
216
  let suggestionMatches = [];
213
217
  let selectedSuggestion = 0;
214
218
 
219
+ function renderBuffer(wasAtBottom = true) {
220
+ history.setContent(buffer);
221
+ if (wasAtBottom) history.setScrollPerc(100);
222
+ screen.render();
223
+ }
224
+
215
225
  function append(text = "") {
226
+ const wasAtBottom = history.getScrollPerc() >= 98;
216
227
  const data = String(text).replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
217
228
  for (const part of data.split("\r")) {
218
229
  if (part === "") continue;
@@ -224,9 +235,7 @@ function createTui() {
224
235
  }
225
236
  }
226
237
  if (buffer.length > 250000) buffer = buffer.slice(-220000);
227
- history.setContent(buffer);
228
- history.setScrollPerc(100);
229
- screen.render();
238
+ renderBuffer(wasAtBottom);
230
239
  }
231
240
 
232
241
  function hideSuggestions() {
@@ -314,30 +323,85 @@ function createTui() {
314
323
  });
315
324
 
316
325
  screen.key(["escape"], hideSuggestions);
326
+ screen.key(["pageup"], () => { history.scroll(-Math.max(3, Math.floor(screen.height * 0.8))); screen.render(); });
327
+ screen.key(["pagedown"], () => { history.scroll(Math.max(3, Math.floor(screen.height * 0.8))); screen.render(); });
328
+ screen.key(["home"], () => { history.setScrollPerc(0); screen.render(); });
329
+ screen.key(["end"], () => { history.setScrollPerc(100); screen.render(); });
317
330
  screen.key(["C-c"], () => onExit());
318
331
 
319
332
  input.focus();
320
333
  screen.render();
321
334
 
322
335
  return {
336
+ supportsReplace: true,
323
337
  screen,
324
338
  input,
325
339
  append,
326
340
  print: (text = "") => append(String(text) + "\n"),
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); },
327
344
  setSubmitHandler: (fn) => { onSubmit = fn; },
328
345
  setExitHandler: (fn) => { onExit = fn; },
329
346
  focus: () => { input.focus(); screen.render(); },
330
347
  setPrompt: (label) => { input.setLabel(` ${label} `); screen.render(); },
331
- 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 (_) {} },
332
396
  };
333
397
  }
334
398
 
335
399
  /* ─── main ─── */
336
400
 
337
- function main() {
401
+ async function main() {
338
402
  if (process.argv.includes("-h") || process.argv.includes("--help")) {
339
403
  console.log("\n COFOS CLI — 9B + RAG chat (Willlzh/COFOS)\n");
340
- 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");
341
405
  console.log(" Commands in chat:");
342
406
  console.log(" /help Show this help");
343
407
  console.log(" /exit Quit");
@@ -349,6 +413,9 @@ function main() {
349
413
  console.log(" Startup options:");
350
414
  console.log(" --pdf-dir DIR Add PDFs from DIR as extra BM25 evidence");
351
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");
352
419
  console.log(" --rebuild-pdf-index Force reparsing PDFs and rebuilding BM25\n");
353
420
  console.log(" Multi-line: end a line with \\ to continue on the next line.\n");
354
421
  process.exit(0);
@@ -361,8 +428,11 @@ function main() {
361
428
  let configPath = DEFAULT_CONFIG;
362
429
  let topK = "5";
363
430
  let pdfDir = null;
431
+ let maxNewTokens = null;
364
432
  let cacheDir = process.env.COFOS_CACHE_DIR ? resolve(process.env.COFOS_CACHE_DIR) : resolve(process.cwd(), ".cofos");
365
433
  let rebuildPdfIndex = false;
434
+ let useTui = false;
435
+ let singlePrompt = null;
366
436
  const backendArgs = [chatScript];
367
437
 
368
438
  const die = (msg) => { console.error("[cofos] " + msg); process.exit(1); };
@@ -386,10 +456,17 @@ function main() {
386
456
  } else if (arg === "--pdf-dir") {
387
457
  pdfDir = resolve(value(++i, arg));
388
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}"`);
389
462
  } else if (arg === "--cache-dir") {
390
463
  cacheDir = resolve(value(++i, arg));
391
464
  } else if (arg === "--rebuild-pdf-index") {
392
465
  rebuildPdfIndex = true;
466
+ } else if (arg === "--tui") {
467
+ useTui = true;
468
+ } else if (arg === "-p" || arg === "--prompt") {
469
+ singlePrompt = value(++i, arg);
393
470
  } else if (arg === "--no-rag") {
394
471
  backendArgs.push("--no-rag");
395
472
  } else {
@@ -398,14 +475,15 @@ function main() {
398
475
  }
399
476
  backendArgs.push("--merged-model-path", modelPath, "--config", configPath, "--top-k", topK);
400
477
  if (pdfDir) backendArgs.push("--pdf-dir", pdfDir);
478
+ if (maxNewTokens) backendArgs.push("--max-new-tokens", maxNewTokens);
401
479
  if (rebuildPdfIndex) backendArgs.push("--rebuild-pdf-index");
402
480
 
403
481
  const py = findPy();
404
482
  if (!py) die("Python not found. Install Python 3.9+ and re-run.");
405
483
  if (!existsSync(chatScript)) die(`Missing script: ${chatScript}`);
406
484
 
407
- const ui = createTui();
408
- 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 }));
409
487
  ensureDeps(py, (msg) => ui.print(msg));
410
488
  ui.print(" Loading COFOS 9B + RAG backend from Hugging Face … first run may take a while");
411
489
  if (pdfDir) ui.print(" Local PDF RAG: enabled for this session");
@@ -432,6 +510,7 @@ function main() {
432
510
  proc.stderr.setEncoding("utf-8");
433
511
 
434
512
  let buf = "", ready = false, generating = false, responseOpen = false, commandOpen = false, closed = false;
513
+ let currentAnswer = "", assistantStart = -1;
435
514
  const inputQ = [];
436
515
  let mlBuf = [];
437
516
 
@@ -441,6 +520,7 @@ function main() {
441
520
  try { proc.stdin.end(); } catch (_) {}
442
521
  try { proc.kill("SIGTERM"); } catch (_) {}
443
522
  ui.destroy();
523
+ process.exit(0);
444
524
  }
445
525
 
446
526
  ui.setExitHandler(shutdown);
@@ -471,21 +551,30 @@ function main() {
471
551
  input = input.trim();
472
552
  if (!input) return false;
473
553
 
474
- if (/^\/(exit|quit)$/.test(input)) { proc.stdin.write("/exit\n"); proc.stdin.end(); return true; }
554
+ if (/^\/(exit|quit)$/.test(input)) { shutdown(); return true; }
475
555
  if (/^\/(help)$/.test(input)) {
476
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.");
477
557
  return false;
478
558
  }
559
+ if (/^\/(cls)$/.test(input)) {
560
+ ui.clear();
561
+ return false;
562
+ }
479
563
  if (/^\/(clear)$/.test(input)) {
564
+ ui.clear();
480
565
  mlBuf = [];
481
566
  responseOpen = false;
567
+ assistantStart = -1;
568
+ currentAnswer = "";
482
569
  commandOpen = true;
483
570
  proc.stdin.write(input + "\n");
484
571
  generating = true;
485
572
  return true;
486
573
  }
487
- 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)) {
488
575
  responseOpen = false;
576
+ assistantStart = -1;
577
+ currentAnswer = "";
489
578
  commandOpen = true;
490
579
  proc.stdin.write(input + "\n");
491
580
  generating = true;
@@ -495,9 +584,15 @@ function main() {
495
584
  ui.print(`\n COFOS CLI — v${pkg.version}\n Model: ${modelPath} (Hugging Face)\n RAG: Willlzh/COFOS_data/runtime (KG + BM25) + optional --pdf-dir BM25 evidence\n Cache: ${cacheDir}\n HF: ${backendEnv.HF_HOME}\n Engine: StudentAdapterInference + transformers + PyTorch\n Hist: ~/.cofos_history.jsonl\n`);
496
585
  return false;
497
586
  }
587
+ if (input.startsWith("/")) {
588
+ ui.print(`{red-fg}•{/red-fg} Unknown command: ${input}. Press / then Tab for commands, or run /help.`);
589
+ return false;
590
+ }
498
591
 
499
- ui.print(Y + "● " + R + input);
592
+ ui.print("{yellow-fg}{/yellow-fg} " + input);
500
593
  responseOpen = false;
594
+ assistantStart = -1;
595
+ currentAnswer = "";
501
596
  generating = true;
502
597
  proc.stdin.write(input + "\n");
503
598
  return true;
@@ -510,6 +605,42 @@ function main() {
510
605
 
511
606
  proc.stderr.on("data", (chunk) => ui.append(chunk));
512
607
 
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}";
622
+ }
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}";
634
+ }
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));
641
+ }
642
+
643
+
513
644
  proc.stdout.on("data", (chunk) => {
514
645
  buf += chunk;
515
646
  if (!ready) {
@@ -518,6 +649,8 @@ function main() {
518
649
  ready = true;
519
650
  buf = buf.slice(i + READY.length);
520
651
  if (buf[0] === "\n") buf = buf.slice(1);
652
+ if (singlePrompt) inputQ.push(singlePrompt);
653
+ else ui.focus();
521
654
  drain();
522
655
  }
523
656
  return;
@@ -528,12 +661,13 @@ function main() {
528
661
  const di = buf.indexOf(DONE);
529
662
  if (di === -1) return;
530
663
  const pre = buf.slice(0, di).trim();
531
- if (pre) ui.print(G + "• " + R + pre);
664
+ if (pre) ui.print("{gray-fg}{/gray-fg} " + pre);
532
665
  buf = buf.slice(di + DONE.length);
533
666
  if (buf[0] === "\n") buf = buf.slice(1);
534
667
  generating = false;
535
668
  commandOpen = false;
536
- ui.focus();
669
+ if (singlePrompt && inputQ.length === 0) shutdown();
670
+ else ui.focus();
537
671
  drain();
538
672
  return;
539
673
  }
@@ -548,25 +682,29 @@ function main() {
548
682
  buf = buf.slice(ri + RESPONSE.length);
549
683
  if (buf[0] === "\n") buf = buf.slice(1);
550
684
  responseOpen = true;
551
- ui.append(GR + B + "◆ COFOS " + R);
685
+ assistantStart = ui.supportsReplace ? ui.length() : 0;
686
+ currentAnswer = "";
552
687
  }
553
688
 
554
689
  const di = buf.indexOf(DONE);
555
690
  if (di !== -1) {
556
691
  const pre = buf.slice(0, di);
557
- if (pre) ui.append(pre);
692
+ if (pre) { currentAnswer += pre; redrawAssistant(); }
558
693
  buf = buf.slice(di + DONE.length);
559
694
  if (buf[0] === "\n") buf = buf.slice(1);
695
+ if (ui.supportsReplace) redrawAssistant();
696
+ else ui.print(formatAssistant(currentAnswer));
560
697
  generating = false;
561
698
  responseOpen = false;
562
- ui.append("\n");
563
- ui.focus();
699
+ if (ui.supportsReplace) ui.append("\n");
700
+ if (singlePrompt && inputQ.length === 0) shutdown();
701
+ else ui.focus();
564
702
  drain();
565
703
  } else {
566
704
  const keep = DONE.length - 1;
567
705
  if (buf.length > keep) {
568
706
  const out = buf.slice(0, -keep);
569
- if (out) ui.append(out);
707
+ if (out) { currentAnswer += out; redrawAssistant(); }
570
708
  buf = buf.slice(-keep);
571
709
  }
572
710
  }
@@ -574,12 +712,18 @@ function main() {
574
712
 
575
713
  proc.on("error", (e) => { ui.print("[cofos] Python error: " + e.message); shutdown(); });
576
714
  proc.on("exit", (c) => {
577
- closed = true;
578
- if (c && c !== 0) ui.print("[cofos] Python exited with code " + c);
579
- 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();
580
719
  process.exitCode = c ?? 0;
581
720
  });
582
721
 
583
722
  }
584
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
+
585
729
  main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lzhzzzzwill/cofos",
3
- "version": "1.2.3",
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": {