@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 +102 -32
- package/package.json +1 -1
- package/scripts/chat.py +29 -8
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 ||
|
|
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
|
|
44
|
-
function
|
|
45
|
-
const
|
|
46
|
-
if (
|
|
47
|
-
|
|
48
|
-
return
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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("
|
|
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
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
|
-
#
|
|
593
|
-
#
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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")
|