@lzhzzzzwill/cofos 1.1.10 → 1.1.14

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.
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 { createInterface } from "node:readline";
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";
@@ -28,47 +28,101 @@ const S_ = [" ██████╗ ","██╔════╝ ","████
28
28
  const ART = C_.map((_, i) => [C_[i], O_[i], F_[i], O_[i], S_[i]].join(" "));
29
29
  const ART_W = ART[0].length;
30
30
 
31
- function shadow() {
32
- let s = " ";
33
- for (const ch of ART[ART.length - 1]) s += ch !== " " ? "░" : " ";
34
- return G + D + s.slice(0, -2) + " " + R;
35
- }
36
-
37
31
  function vis(t) { return t.replace(/\x1b\[[0-9;]*m/g, "").length; }
38
- function fw() { return Math.max((process.stdout.columns || 100) - 2, 50); }
39
-
32
+ function fw() { return Math.min(Math.max((process.stdout.columns || 110) - 2, 82), 150); }
40
33
  function top(w) { return "╭" + "─".repeat(w) + "╮"; }
41
34
  function bot(w) { return "╰" + "─".repeat(w) + "╯"; }
42
35
  function sep(w) { return "├" + "─".repeat(w) + "┤"; }
43
- function emp(w) { return "│" + " ".repeat(w) + "│"; }
44
- function cnt(t, w) {
45
- const n = vis(t);
46
- if (n >= w - 1) return "│" + t.slice(0, w) + "│";
47
- const s = w - n, l = Math.floor(s / 2), r = s - l;
48
- return "│" + " ".repeat(l) + t + " ".repeat(r) + "";
36
+ function vpad(t, w) { return t + " ".repeat(Math.max(0, w - vis(t))); }
37
+ function clip(t, w) {
38
+ const plain = t.replace(/\x1b\[[0-9;]*m/g, "");
39
+ if (plain.length <= w) return t;
40
+ if (w <= 1) return "…";
41
+ return plain.slice(0, w - 1) + "";
42
+ }
43
+ function cell(t, w) { return vpad(clip(t, w), w); }
44
+ function row(left, right, lw, rw) { return "│ " + cell(left, lw) + " │ " + cell(right, rw) + " │"; }
45
+ function full(t, w) { return "│ " + cell(t, w - 2) + " │"; }
46
+ function empty(w) { return full("", w); }
47
+
48
+ function logoLines(width) {
49
+ if (width >= ART_W) return ART.map((line) => C + B + line + R);
50
+ return [C + B + "COFOS" + R];
49
51
  }
50
52
 
51
- function render() {
53
+ function render({ modelPath, cacheDir, pdfDir }) {
52
54
  console.clear();
53
55
  const w = fw();
54
- const l = [];
55
- l.push(top(w)); l.push(emp(w));
56
- if (w >= ART_W + 2) {
57
- for (const r of ART) l.push(cnt(C + B + r + R, w));
58
- l.push(cnt(shadow(), w));
56
+ const minSplit = 96;
57
+ const cwd = process.cwd();
58
+ const rag = pdfDir ? "RAG: KG + BM25 + local PDFs" : "RAG: KG + BM25";
59
+ const left = [
60
+ ...logoLines(Math.floor((w - 5) * 0.48)),
61
+ "",
62
+ G + "Scientific RAG CLI" + R,
63
+ W + "Model" + R + " " + modelPath,
64
+ W + "Params" + R + " 9B",
65
+ W + "Path" + R + " " + cwd,
66
+ W + "Cache" + R + " " + cacheDir,
67
+ ];
68
+ const right = [
69
+ W + B + "What COFOS Does" + R,
70
+ "COFOS answers scientific questions about covalent organic frameworks,",
71
+ "photocatalysis, reactive oxygen species, and oxygen-reduction products.",
72
+ rag,
73
+ "",
74
+ W + B + "Suggested Commands" + R,
75
+ "/help show commands and PDF-RAG usage",
76
+ "/topk 5 change retrieval depth",
77
+ "/rag off answer without retrieval",
78
+ "cofos --pdf-dir ./new_pdfs add local papers",
79
+ ];
80
+
81
+ const lines = [top(w)];
82
+ if (w >= minSplit) {
83
+ const inner = w - 5;
84
+ const lw = Math.max(46, Math.floor(inner * 0.48));
85
+ const rw = inner - lw;
86
+ const n = Math.max(left.length, right.length);
87
+ for (let i = 0; i < n; i++) lines.push(row(left[i] || "", right[i] || "", lw, rw));
59
88
  } else {
60
- l.push(cnt(C + B + " ██████╗ ██████╗ ███████╗ ██████╗ ███████╗ " + R, w));
61
- l.push(cnt(G + D + " ░░░░░░╝ ░░░░░░╝ ░░░░░░░╝ ░░░░░░╝ ░░░░░░░╝ " + R, w));
89
+ for (const item of left) lines.push(full(item, w));
90
+ lines.push(sep(w));
91
+ for (const item of right) lines.push(full(item, w));
62
92
  }
63
- l.push(emp(w));
64
- l.push(sep(w));
65
- l.push(emp(w));
66
- l.push(cnt(W + B + "Welcome to COFOS CLI — 9B + RAG chat (Willlzh/COFOS)" + R, w));
67
- l.push(emp(w));
68
- l.push(cnt(G + "Type /help · /exit · /topk 5 · /rag off · \\ for multiline" + R, w));
69
- l.push(emp(w));
70
- l.push(bot(w));
71
- console.log(l.join("\n"));
93
+ lines.push(bot(w));
94
+ console.log(lines.join("\n"));
95
+ }
96
+
97
+ /* ─── Slash commands ─── */
98
+ const SLASH_COMMANDS = [
99
+ { cmd: "/help", desc: "Show help" },
100
+ { cmd: "/exit", desc: "Quit" },
101
+ { cmd: "/clear", desc: "Clear conversation history" },
102
+ { cmd: "/save", desc: "Save this session history" },
103
+ { cmd: "/info", desc: "Show model and RAG info" },
104
+ { cmd: "/topk 5", desc: "Set retrieval top-k" },
105
+ { cmd: "/rag on", desc: "Enable RAG" },
106
+ { cmd: "/rag off", desc: "Disable RAG" },
107
+ ];
108
+
109
+ function slashMatches(line) {
110
+ const needle = line.trim().toLowerCase();
111
+ return SLASH_COMMANDS.filter(({ cmd }) => cmd.toLowerCase().startsWith(needle));
112
+ }
113
+
114
+ function slashCompletions(line) {
115
+ if (!line.startsWith("/")) return [[], line];
116
+ const hits = slashMatches(line).map(({ cmd }) => cmd);
117
+ return [hits.length ? hits : SLASH_COMMANDS.map(({ cmd }) => cmd), line];
118
+ }
119
+
120
+ function slashHint(line = "/") {
121
+ const hits = slashMatches(line);
122
+ const rows = (hits.length ? hits : SLASH_COMMANDS)
123
+ .map(({ cmd, desc }) => ` ${cmd.padEnd(10)} ${desc}`)
124
+ .join("\n");
125
+ return `\n Commands\n${rows}\n Press Tab to complete.\n`;
72
126
  }
73
127
 
74
128
  /* ─── helpers ─── */
@@ -155,7 +209,7 @@ function main() {
155
209
  if (pdfDir) backendArgs.push("--pdf-dir", pdfDir);
156
210
  if (rebuildPdfIndex) backendArgs.push("--rebuild-pdf-index");
157
211
 
158
- render();
212
+ render({ modelPath, cacheDir, pdfDir });
159
213
 
160
214
  const py = findPy();
161
215
  if (!py) die("Python not found. Install Python 3.9+ and re-run.");
@@ -198,11 +252,31 @@ function main() {
198
252
  output: process.stdout,
199
253
  terminal: true,
200
254
  prompt: mainPrompt,
255
+ completer: slashCompletions,
201
256
  });
257
+ let lastSlashHint = "";
202
258
  rl.on("line", (l) => {
259
+ lastSlashHint = "";
203
260
  if (!ready || generating) inputQ.push(l);
204
261
  else if (!handle(l)) ask();
205
262
  });
263
+ if (process.stdin.isTTY) {
264
+ emitKeypressEvents(process.stdin, rl);
265
+ process.stdin.on("keypress", () => {
266
+ if (!ready || generating || closed) return;
267
+ const line = rl.line || "";
268
+ if (!line.startsWith("/")) {
269
+ lastSlashHint = "";
270
+ return;
271
+ }
272
+ const key = line.trim();
273
+ if (key && key !== lastSlashHint) {
274
+ lastSlashHint = key;
275
+ rl.output.write(slashHint(line));
276
+ rl.prompt(true);
277
+ }
278
+ });
279
+ }
206
280
 
207
281
  // Forward Python stderr (status / progress messages) to our stderr.
208
282
  proc.stderr.on("data", (chunk) => { process.stderr.write(chunk); });
@@ -318,7 +392,7 @@ function main() {
318
392
  // Local-only commands (don't hit the backend).
319
393
  if (/^\/(exit|quit)$/.test(input)) { proc.stdin.write("/exit\n"); proc.stdin.end(); return true; }
320
394
  if (/^\/(help)$/.test(input)) {
321
- console.log("\n Commands:\n /help Show help\n /exit Quit\n /clear Clear conversation history\n /save Persist conversation history\n /info Model and RAG info\n /topk N Set retrieval top-k\n /rag on|off Enable/disable RAG\n\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.\n");
395
+ console.log(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.\n");
322
396
  return false;
323
397
  }
324
398
  if (/^\/(clear)$/.test(input)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lzhzzzzwill/cofos",
3
- "version": "1.1.10",
3
+ "version": "1.1.14",
4
4
  "description": "COFOS 9B + RAG chat CLI with optional local PDF ingestion.",
5
5
  "type": "module",
6
6
  "bin": {
package/scripts/chat.py CHANGED
@@ -99,6 +99,11 @@ def parse_args() -> argparse.Namespace:
99
99
  parser.add_argument("--pdf-dir", default=os.environ.get("COFOS_PDF_DIR"))
100
100
  parser.add_argument("--rebuild-pdf-index", action="store_true")
101
101
  parser.add_argument("--no-rag", action="store_true")
102
+ parser.add_argument(
103
+ "--restore-history",
104
+ action="store_true",
105
+ default=os.environ.get("COFOS_RESTORE_HISTORY", "").lower() in {"1", "true", "yes", "on"},
106
+ )
102
107
  return parser.parse_args()
103
108
 
104
109
 
@@ -381,6 +386,20 @@ def _clean_history_content(role: str, content: str) -> str:
381
386
  content = re.sub(r"(?is)^.*?</think>", "", content).strip()
382
387
  return content
383
388
 
389
+
390
+ def _looks_like_corrupt_generation(answer: str) -> bool:
391
+ text = answer.strip()
392
+ if len(text) < 20:
393
+ return False
394
+ visible = [ch for ch in text if not ch.isspace()]
395
+ if not visible:
396
+ return True
397
+ alnum = sum(ch.isalnum() for ch in visible)
398
+ letters = sum(ch.isalpha() for ch in visible)
399
+ punct = sum((not ch.isalnum()) for ch in visible)
400
+ repeated_digits = bool(re.search(r"\d(?:\s*\d){8,}", text))
401
+ return (letters / len(visible) < 0.20 and punct / len(visible) > 0.35) or repeated_digits
402
+
384
403
  def _load_history() -> list:
385
404
  """Load recent conversation turns from the persistent history file.
386
405
 
@@ -520,8 +539,8 @@ def main() -> None:
520
539
  print("===READY===", flush=True)
521
540
 
522
541
  top_k = args.top_k
523
- history = _load_history()
524
- if history:
542
+ history = _load_history() if args.restore_history else []
543
+ if history and os.environ.get("COFOS_DEBUG", "").lower() in {"1", "true", "yes", "on"}:
525
544
  print(f"[chat] Restored {len(history) // 2} previous turns from history.",
526
545
  file=sys.stderr, flush=True)
527
546
 
@@ -589,12 +608,14 @@ def main() -> None:
589
608
  if os.environ.get("COFOS_DEBUG", "").lower() in {"1", "true", "yes", "on"}:
590
609
  print(f"[chat] Generated in {gen_elapsed:.1f}s", file=sys.stderr, flush=True)
591
610
 
592
- # Persist both turns only once the answer completed, so an
593
- # interrupted generation cannot leave a dangling user turn behind.
594
- history.append({"role": "user", "content": question})
595
- history.append({"role": "assistant", "content": answer})
596
- _save_turn("user", question)
597
- _save_turn("assistant", answer)
611
+ # Keep history in-memory for the current session. Persist only when
612
+ # the user explicitly runs /save, so a bad generation cannot poison
613
+ # future launches.
614
+ if _looks_like_corrupt_generation(answer):
615
+ print("[COFOS warning] The generated text looked corrupted and was not kept in history.", file=sys.stderr, flush=True)
616
+ else:
617
+ history.append({"role": "user", "content": question})
618
+ history.append({"role": "assistant", "content": answer})
598
619
  except Exception as exc:
599
620
  if os.environ.get("COFOS_DEBUG", "").lower() in {"1", "true", "yes", "on"}:
600
621
  logging.exception("Generation failed")