@lzhzzzzwill/cofos 1.1.10 → 1.1.13

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,97 @@ 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) + "";
49
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); }
50
47
 
51
48
  function render() {
52
49
  console.clear();
53
50
  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));
51
+ const minSplit = 96;
52
+ const cwd = process.cwd();
53
+ const rag = "RAG: KG + BM25 + optional PDFs";
54
+ const leftTitle = C + B + "COFOS" + R;
55
+ const left = [
56
+ leftTitle,
57
+ G + "9B scientific RAG CLI" + R,
58
+ "",
59
+ W + "Model" + R + " Willlzh/COFOS",
60
+ W + "Params" + R + " 9B",
61
+ W + "Path" + R + " " + cwd,
62
+ ];
63
+ const right = [
64
+ W + B + "What COFOS Does" + R,
65
+ "Answers COF / photocatalysis / ROS questions with a local 9B model.",
66
+ rag,
67
+ "",
68
+ W + B + "Suggested Commands" + R,
69
+ "/help show commands and PDF-RAG usage",
70
+ "/topk 5 change retrieval depth",
71
+ "/rag off answer without retrieval",
72
+ "cofos --pdf-dir ./new_pdfs add local papers",
73
+ ];
74
+
75
+ const lines = [top(w)];
76
+ if (w >= minSplit) {
77
+ const inner = w - 5;
78
+ const lw = Math.max(28, Math.floor(inner * 0.34));
79
+ const rw = inner - lw;
80
+ const n = Math.max(left.length, right.length);
81
+ for (let i = 0; i < n; i++) lines.push(row(left[i] || "", right[i] || "", lw, rw));
59
82
  } else {
60
- l.push(cnt(C + B + " ██████╗ ██████╗ ███████╗ ██████╗ ███████╗ " + R, w));
61
- l.push(cnt(G + D + " ░░░░░░╝ ░░░░░░╝ ░░░░░░░╝ ░░░░░░╝ ░░░░░░░╝ " + R, w));
83
+ for (const item of left) lines.push(full(item, w));
84
+ lines.push(sep(w));
85
+ for (const item of right) lines.push(full(item, w));
62
86
  }
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"));
87
+ lines.push(empty(w));
88
+ lines.push(full(G + "Type / for commands · /exit · /topk 5 · /rag off · \\ for multiline" + R, w));
89
+ lines.push(bot(w));
90
+ console.log(lines.join("\n"));
91
+ }
92
+
93
+ /* ─── Slash commands ─── */
94
+ const SLASH_COMMANDS = [
95
+ { cmd: "/help", desc: "Show help" },
96
+ { cmd: "/exit", desc: "Quit" },
97
+ { cmd: "/clear", desc: "Clear conversation history" },
98
+ { cmd: "/save", desc: "Save this session history" },
99
+ { cmd: "/info", desc: "Show model and RAG info" },
100
+ { cmd: "/topk 5", desc: "Set retrieval top-k" },
101
+ { cmd: "/rag on", desc: "Enable RAG" },
102
+ { cmd: "/rag off", desc: "Disable RAG" },
103
+ ];
104
+
105
+ function slashMatches(line) {
106
+ const needle = line.trim().toLowerCase();
107
+ return SLASH_COMMANDS.filter(({ cmd }) => cmd.toLowerCase().startsWith(needle));
108
+ }
109
+
110
+ function slashCompletions(line) {
111
+ if (!line.startsWith("/")) return [[], line];
112
+ const hits = slashMatches(line).map(({ cmd }) => cmd);
113
+ return [hits.length ? hits : SLASH_COMMANDS.map(({ cmd }) => cmd), line];
114
+ }
115
+
116
+ function slashHint(line = "/") {
117
+ const hits = slashMatches(line);
118
+ const rows = (hits.length ? hits : SLASH_COMMANDS)
119
+ .map(({ cmd, desc }) => ` ${cmd.padEnd(10)} ${desc}`)
120
+ .join("\n");
121
+ return `\n Commands\n${rows}\n Press Tab to complete.\n`;
72
122
  }
73
123
 
74
124
  /* ─── helpers ─── */
@@ -198,11 +248,31 @@ function main() {
198
248
  output: process.stdout,
199
249
  terminal: true,
200
250
  prompt: mainPrompt,
251
+ completer: slashCompletions,
201
252
  });
253
+ let lastSlashHint = "";
202
254
  rl.on("line", (l) => {
255
+ lastSlashHint = "";
203
256
  if (!ready || generating) inputQ.push(l);
204
257
  else if (!handle(l)) ask();
205
258
  });
259
+ if (process.stdin.isTTY) {
260
+ emitKeypressEvents(process.stdin, rl);
261
+ process.stdin.on("keypress", () => {
262
+ if (!ready || generating || closed) return;
263
+ const line = rl.line || "";
264
+ if (!line.startsWith("/")) {
265
+ lastSlashHint = "";
266
+ return;
267
+ }
268
+ const key = line.trim();
269
+ if (key && key !== lastSlashHint) {
270
+ lastSlashHint = key;
271
+ rl.output.write(slashHint(line));
272
+ rl.prompt(true);
273
+ }
274
+ });
275
+ }
206
276
 
207
277
  // Forward Python stderr (status / progress messages) to our stderr.
208
278
  proc.stderr.on("data", (chunk) => { process.stderr.write(chunk); });
@@ -318,7 +388,7 @@ function main() {
318
388
  // Local-only commands (don't hit the backend).
319
389
  if (/^\/(exit|quit)$/.test(input)) { proc.stdin.write("/exit\n"); proc.stdin.end(); return true; }
320
390
  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");
391
+ 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
392
  return false;
323
393
  }
324
394
  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.13",
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")