@chessceo/mcp 0.44.0 → 0.46.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.
- package/dist/analysis/auto.js +291 -0
- package/dist/analysis/deep.js +157 -0
- package/dist/analysis/file_handle.js +156 -0
- package/dist/analysis/response.js +181 -0
- package/dist/courses.js +167 -0
- package/dist/index.js +13 -1501
- package/dist/pgn/exporter.js +13 -2
- package/dist/pgn/parser.js +46 -7
- package/dist/pgn/paths.js +37 -14
- package/dist/pgn/types.js +8 -5
- package/dist/prep/library.js +92 -0
- package/dist/prep/mutations.js +170 -0
- package/dist/prep/read.js +242 -0
- package/dist/response_transforms.js +169 -0
- package/package.json +1 -1
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Shared engine-response helpers used across cloud_analyse, auto_evaluate,
|
|
2
|
+
// deep_analyse, and get_position_stats. All PGN-shape data comes back
|
|
3
|
+
// from the backend in UCI ("g1f3") — this module converts to SAN, extracts
|
|
4
|
+
// stored evals from the raw JSON, and derives the eval → NAG mapping.
|
|
5
|
+
//
|
|
6
|
+
// Extracted from src/index.ts in v0.44 as part of the file split.
|
|
7
|
+
import { Chess } from "chess.js";
|
|
8
|
+
import { authedRequest } from "../http.js";
|
|
9
|
+
// Convert a UCI move sequence into SAN by walking it move-by-move on
|
|
10
|
+
// chess.js from the given starting FEN. LLMs reason far better in SAN
|
|
11
|
+
// ("Nf3", "Bxc4") than UCI ("g1f3", "b5c4"), and matches how prep
|
|
12
|
+
// discussion is written in the real world. If a move fails to parse
|
|
13
|
+
// (illegal from the current position — bug or truncated PV), we
|
|
14
|
+
// truncate cleanly rather than throwing so the response still carries
|
|
15
|
+
// what we could convert.
|
|
16
|
+
export function uciLineToSAN(startFen, uciMoves) {
|
|
17
|
+
const board = new Chess(startFen);
|
|
18
|
+
const out = [];
|
|
19
|
+
for (const uci of uciMoves) {
|
|
20
|
+
if (uci.length < 4)
|
|
21
|
+
break;
|
|
22
|
+
try {
|
|
23
|
+
const move = board.move({
|
|
24
|
+
from: uci.slice(0, 2),
|
|
25
|
+
to: uci.slice(2, 4),
|
|
26
|
+
promotion: uci.length >= 5 ? uci[4] : undefined,
|
|
27
|
+
});
|
|
28
|
+
if (!move)
|
|
29
|
+
break;
|
|
30
|
+
out.push(move.san);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
export function uciMoveToSAN(startFen, uci) {
|
|
39
|
+
if (!uci || uci.length < 4)
|
|
40
|
+
return uci;
|
|
41
|
+
const board = new Chess(startFen);
|
|
42
|
+
try {
|
|
43
|
+
const move = board.move({
|
|
44
|
+
from: uci.slice(0, 2),
|
|
45
|
+
to: uci.slice(2, 4),
|
|
46
|
+
promotion: uci.length >= 5 ? uci[4] : undefined,
|
|
47
|
+
});
|
|
48
|
+
return move ? move.san : uci;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return uci;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// Fetch a compact cloud eval for `fen`. Returns null on any error — no
|
|
55
|
+
// running combo instance, engine failure, network timeout. Callers
|
|
56
|
+
// attach the result to their response as `.eval` so the LLM has the
|
|
57
|
+
// stockfish + lc0 read without a separate tool call.
|
|
58
|
+
export async function fetchCompactEval(fen) {
|
|
59
|
+
try {
|
|
60
|
+
const raw = await authedRequest("POST", "/api/agent/cloud-engines/analyse", { fen, movetime_ms: 1500, multipv: 1 });
|
|
61
|
+
const converted = convertCloudSnapshotResponse(raw, fen);
|
|
62
|
+
const stored = analysisToStoredEval(converted);
|
|
63
|
+
return storedEvalToCompact(stored, converted);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// Trim every PV in a converted cloud-analyse response to `maxPlies`
|
|
70
|
+
// and mark each trimmed line with `pv_truncated: true` so the LLM
|
|
71
|
+
// sees what happened. Applied ONLY to cloud_analyse (short synchronous
|
|
72
|
+
// snapshot); deep_analyse is the explicit "give me the deep line"
|
|
73
|
+
// tool and keeps its full PV.
|
|
74
|
+
export function capPvsInResponse(converted, maxPlies) {
|
|
75
|
+
if (!converted || typeof converted !== "object")
|
|
76
|
+
return;
|
|
77
|
+
const r = converted;
|
|
78
|
+
for (const eng of [r.stockfish, r.lc0]) {
|
|
79
|
+
if (!eng || !Array.isArray(eng.lines))
|
|
80
|
+
continue;
|
|
81
|
+
for (const line of eng.lines) {
|
|
82
|
+
if (Array.isArray(line.pv) && line.pv.length > maxPlies) {
|
|
83
|
+
line.pv = line.pv.slice(0, maxPlies);
|
|
84
|
+
line.pv_truncated = true;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
converted.pv_max_plies = maxPlies;
|
|
89
|
+
}
|
|
90
|
+
export function convertCloudSnapshotResponse(raw, startFen) {
|
|
91
|
+
if (!raw || typeof raw !== "object")
|
|
92
|
+
return raw;
|
|
93
|
+
const r = raw;
|
|
94
|
+
for (const eng of [r.stockfish, r.lc0]) {
|
|
95
|
+
if (!eng)
|
|
96
|
+
continue;
|
|
97
|
+
if (Array.isArray(eng.lines)) {
|
|
98
|
+
for (const line of eng.lines) {
|
|
99
|
+
if (Array.isArray(line.pv))
|
|
100
|
+
line.pv = uciLineToSAN(startFen, line.pv);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (typeof eng.bestMove === "string")
|
|
104
|
+
eng.bestMove = uciMoveToSAN(startFen, eng.bestMove);
|
|
105
|
+
}
|
|
106
|
+
return raw;
|
|
107
|
+
}
|
|
108
|
+
export function analysisToStoredEval(analysis) {
|
|
109
|
+
if (!analysis || typeof analysis !== "object")
|
|
110
|
+
return null;
|
|
111
|
+
const r = analysis;
|
|
112
|
+
// Backend returns White-POV cp/mate (engine-ws flips in ParseInfo
|
|
113
|
+
// based on side-to-move, cloud_snapshot passes through). Pure
|
|
114
|
+
// pass-through here — a previous sign-flip on black-to-move was
|
|
115
|
+
// wrong and silently inverted every Black-to-move stored eval.
|
|
116
|
+
const engineEval = (block) => {
|
|
117
|
+
const line = block?.lines?.[0];
|
|
118
|
+
if (!line)
|
|
119
|
+
return undefined;
|
|
120
|
+
const depth = line.depth ?? block?.depth;
|
|
121
|
+
if (typeof line.mate === "number")
|
|
122
|
+
return { mate: line.mate, depth };
|
|
123
|
+
if (typeof line.scoreCp === "number")
|
|
124
|
+
return { cp: line.scoreCp, depth };
|
|
125
|
+
return undefined;
|
|
126
|
+
};
|
|
127
|
+
const sf = engineEval(r.stockfish);
|
|
128
|
+
const lc0 = engineEval(r.lc0);
|
|
129
|
+
if (!sf && !lc0)
|
|
130
|
+
return null;
|
|
131
|
+
const ev = {};
|
|
132
|
+
if (sf)
|
|
133
|
+
ev.sf = sf;
|
|
134
|
+
if (lc0)
|
|
135
|
+
ev.lc0 = lc0;
|
|
136
|
+
ev.nag = nagFromCp(sf?.cp, sf?.mate) ?? nagFromCp(lc0?.cp, lc0?.mate) ?? undefined;
|
|
137
|
+
return ev;
|
|
138
|
+
}
|
|
139
|
+
export function nagFromCp(cp, mate) {
|
|
140
|
+
let effective;
|
|
141
|
+
if (typeof mate === "number")
|
|
142
|
+
effective = mate > 0 ? 10000 : -10000;
|
|
143
|
+
else if (typeof cp === "number")
|
|
144
|
+
effective = cp;
|
|
145
|
+
else
|
|
146
|
+
return null;
|
|
147
|
+
const abs = Math.abs(effective);
|
|
148
|
+
if (abs < 25)
|
|
149
|
+
return "$10";
|
|
150
|
+
if (abs < 60)
|
|
151
|
+
return effective > 0 ? "$14" : "$15";
|
|
152
|
+
if (abs < 130)
|
|
153
|
+
return effective > 0 ? "$16" : "$17";
|
|
154
|
+
return effective > 0 ? "$18" : "$19";
|
|
155
|
+
}
|
|
156
|
+
// Adapter for the compact eval attached to live query responses. Same
|
|
157
|
+
// derivation logic; different output shape (needs the .nag + summary
|
|
158
|
+
// used by get_position_stats / prep_snapshot).
|
|
159
|
+
export function storedEvalToCompact(ev, analysis) {
|
|
160
|
+
if (!ev)
|
|
161
|
+
return null;
|
|
162
|
+
const a = analysis;
|
|
163
|
+
const compact = { nag: ev.nag ?? null };
|
|
164
|
+
if (ev.sf) {
|
|
165
|
+
compact.stockfish = {
|
|
166
|
+
cp: ev.sf.cp,
|
|
167
|
+
mate: ev.sf.mate,
|
|
168
|
+
bestMove: a.stockfish?.bestMove,
|
|
169
|
+
pv: a.stockfish?.lines?.[0]?.pv,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
if (ev.lc0) {
|
|
173
|
+
compact.lc0 = {
|
|
174
|
+
cp: ev.lc0.cp,
|
|
175
|
+
mate: ev.lc0.mate,
|
|
176
|
+
bestMove: a.lc0?.bestMove,
|
|
177
|
+
pv: a.lc0?.lines?.[0]?.pv,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
return compact;
|
|
181
|
+
}
|
package/dist/courses.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// Subprocess wrappers for two Python-backed helpers:
|
|
2
|
+
// - fenfind / readpgn — polyglot Zobrist search over the user's
|
|
3
|
+
// Chessable / PGN course library (the `find_position_in_courses`
|
|
4
|
+
// and `read_course_at_position` tools).
|
|
5
|
+
// - sf_eval — spawns local stockfish, parses its `eval` verbose
|
|
6
|
+
// output (the eval-terms leg of `describe_position`).
|
|
7
|
+
//
|
|
8
|
+
// Both live outside the pure-TS MCP because they reuse Python code
|
|
9
|
+
// (python-chess for polyglot hashing / PGN parsing; the local
|
|
10
|
+
// stockfish binary for eval terms) that porting to TS would just
|
|
11
|
+
// duplicate.
|
|
12
|
+
//
|
|
13
|
+
// Extracted from index.ts in v0.44 as part of the file split.
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import { existsSync } from "node:fs";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { resolveFromNodeOrFen } from "./analysis/file_handle.js";
|
|
19
|
+
// Path to sf_eval helper (spawns local stockfish, parses its `eval`
|
|
20
|
+
// verbose output). SF_EVAL_PATH env overrides the bundled tools/sf_eval/
|
|
21
|
+
// directory.
|
|
22
|
+
const SF_EVAL_SCRIPT = (() => {
|
|
23
|
+
const envPath = process.env.SF_EVAL_PATH?.trim();
|
|
24
|
+
if (envPath && existsSync(join(envPath, "sf_eval")))
|
|
25
|
+
return join(envPath, "sf_eval");
|
|
26
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
const bundled = join(here, "..", "tools", "sf_eval", "sf_eval");
|
|
28
|
+
return existsSync(bundled) ? bundled : null;
|
|
29
|
+
})();
|
|
30
|
+
const SF_EVAL_TIMEOUT_MS = 12_000;
|
|
31
|
+
export async function runSfEval(fen) {
|
|
32
|
+
if (!SF_EVAL_SCRIPT) {
|
|
33
|
+
return {
|
|
34
|
+
found: false,
|
|
35
|
+
error: "sf_eval script not bundled; set SF_EVAL_PATH or install tools/sf_eval/",
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const stdout = await new Promise((resolve, reject) => {
|
|
39
|
+
const p = spawn(SF_EVAL_SCRIPT, ["--fen", fen], { stdio: ["ignore", "pipe", "pipe"] });
|
|
40
|
+
let out = "";
|
|
41
|
+
let err = "";
|
|
42
|
+
p.stdout.on("data", d => { out += d.toString("utf8"); });
|
|
43
|
+
p.stderr.on("data", d => { err += d.toString("utf8"); });
|
|
44
|
+
const to = setTimeout(() => {
|
|
45
|
+
try {
|
|
46
|
+
p.kill("SIGTERM");
|
|
47
|
+
}
|
|
48
|
+
catch { /* already dead */ }
|
|
49
|
+
reject(new Error(`sf_eval timed out after ${SF_EVAL_TIMEOUT_MS}ms`));
|
|
50
|
+
}, SF_EVAL_TIMEOUT_MS);
|
|
51
|
+
p.on("error", e => { clearTimeout(to); reject(e); });
|
|
52
|
+
p.on("close", code => {
|
|
53
|
+
clearTimeout(to);
|
|
54
|
+
if (code !== 0)
|
|
55
|
+
reject(new Error(`sf_eval exited ${code}: ${err.slice(0, 500)}`));
|
|
56
|
+
else
|
|
57
|
+
resolve(out);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(stdout);
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
throw new Error(`sf_eval returned non-JSON output (${e instanceof Error ? e.message : String(e)}): ${stdout.slice(0, 300)}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// Path resolution order (`FENFIND_PATH` env var overrides):
|
|
68
|
+
// 1. $FENFIND_PATH/fenfind
|
|
69
|
+
// 2. <package-root>/tools/fenfind/fenfind (ships with the npm package)
|
|
70
|
+
// The bash wrapper picks a python interpreter with python-chess
|
|
71
|
+
// available (venv at $here/.venv/bin/python preferred, then falls back
|
|
72
|
+
// to system python3). DB path is resolved inside fenfind.py itself
|
|
73
|
+
// (FENFIND_DB env, then ~/positions.db).
|
|
74
|
+
const FENFIND_DIR = (() => {
|
|
75
|
+
const envPath = process.env.FENFIND_PATH?.trim();
|
|
76
|
+
if (envPath && existsSync(join(envPath, "fenfind")))
|
|
77
|
+
return envPath;
|
|
78
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
79
|
+
const bundled = join(here, "..", "tools", "fenfind");
|
|
80
|
+
return existsSync(join(bundled, "fenfind")) ? bundled : null;
|
|
81
|
+
})();
|
|
82
|
+
// Cap on how long we let the subprocess run. SQLite hash lookup returns
|
|
83
|
+
// sub-second; PGN read from a course file is O(chapter size) and rarely
|
|
84
|
+
// exceeds a second. 15s is a stuck-process backstop, not a real limit.
|
|
85
|
+
const FENFIND_TIMEOUT_MS = 15_000;
|
|
86
|
+
async function runFenfindScript(scriptName, args) {
|
|
87
|
+
if (!FENFIND_DIR) {
|
|
88
|
+
throw new Error("fenfind index not installed — set FENFIND_PATH or install the tools/fenfind bundle");
|
|
89
|
+
}
|
|
90
|
+
const script = join(FENFIND_DIR, scriptName);
|
|
91
|
+
return new Promise((resolve, reject) => {
|
|
92
|
+
const p = spawn(script, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
93
|
+
let out = "";
|
|
94
|
+
let err = "";
|
|
95
|
+
p.stdout.on("data", d => { out += d.toString("utf8"); });
|
|
96
|
+
p.stderr.on("data", d => { err += d.toString("utf8"); });
|
|
97
|
+
const to = setTimeout(() => {
|
|
98
|
+
try {
|
|
99
|
+
p.kill("SIGTERM");
|
|
100
|
+
}
|
|
101
|
+
catch { /* already dead */ }
|
|
102
|
+
reject(new Error(`${scriptName} timed out after ${FENFIND_TIMEOUT_MS}ms`));
|
|
103
|
+
}, FENFIND_TIMEOUT_MS);
|
|
104
|
+
p.on("error", e => { clearTimeout(to); reject(e); });
|
|
105
|
+
p.on("close", code => {
|
|
106
|
+
clearTimeout(to);
|
|
107
|
+
if (code !== 0)
|
|
108
|
+
reject(new Error(`${scriptName} exited ${code}: ${err.slice(0, 500)}`));
|
|
109
|
+
else
|
|
110
|
+
resolve(out);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
function parseFenfindJson(scriptName, stdout) {
|
|
115
|
+
try {
|
|
116
|
+
return JSON.parse(stdout);
|
|
117
|
+
}
|
|
118
|
+
catch (e) {
|
|
119
|
+
throw new Error(`${scriptName} returned non-JSON output (${e instanceof Error ? e.message : String(e)}): ${stdout.slice(0, 300)}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
export async function findPositionInCourses(args) {
|
|
123
|
+
if (!FENFIND_DIR) {
|
|
124
|
+
return {
|
|
125
|
+
status: "not_available",
|
|
126
|
+
note: "fenfind index not installed on this server. Set FENFIND_PATH env var to the directory containing the `fenfind` script and positions.db, or install the tools/fenfind bundle shipped in the npm package.",
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const resolved = await resolveFromNodeOrFen(args);
|
|
130
|
+
const cliArgs = [resolved.fen, "--json"];
|
|
131
|
+
if (typeof args.sort === "string" && (args.sort === "recency" || args.sort === "notes")) {
|
|
132
|
+
cliArgs.push("--sort", args.sort);
|
|
133
|
+
}
|
|
134
|
+
if (args.include_games)
|
|
135
|
+
cliArgs.push("--games");
|
|
136
|
+
if (args.chapters_mode)
|
|
137
|
+
cliArgs.push("--chapters");
|
|
138
|
+
if (typeof args.min_notes_chars === "number")
|
|
139
|
+
cliArgs.push("--min", String(args.min_notes_chars));
|
|
140
|
+
if (typeof args.limit === "number")
|
|
141
|
+
cliArgs.push("-n", String(args.limit));
|
|
142
|
+
const stdout = await runFenfindScript("fenfind", cliArgs);
|
|
143
|
+
return parseFenfindJson("fenfind", stdout);
|
|
144
|
+
}
|
|
145
|
+
export async function readCourseAtPosition(args) {
|
|
146
|
+
if (!FENFIND_DIR) {
|
|
147
|
+
return {
|
|
148
|
+
status: "not_available",
|
|
149
|
+
note: "fenfind index not installed on this server. Set FENFIND_PATH env var to the directory containing the `fenfind`/`readpgn` scripts and positions.db.",
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
const fileId = typeof args.course_file_id === "number" ? args.course_file_id : Number(args.course_file_id);
|
|
153
|
+
if (!Number.isFinite(fileId) || fileId <= 0) {
|
|
154
|
+
throw new Error("`course_file_id` is required — pass the value from a find_position_in_courses hit");
|
|
155
|
+
}
|
|
156
|
+
const cliArgs = ["--file-id", String(fileId)];
|
|
157
|
+
if (typeof args.fen === "string" && args.fen.trim() !== "")
|
|
158
|
+
cliArgs.push("--fen", args.fen.trim());
|
|
159
|
+
if (typeof args.moves === "string" && args.moves.trim() !== "")
|
|
160
|
+
cliArgs.push("--moves", args.moves.trim());
|
|
161
|
+
if (typeof args.chapter === "string" && args.chapter.trim() !== "")
|
|
162
|
+
cliArgs.push("--chapter", args.chapter.trim());
|
|
163
|
+
if (typeof args.max_plies_below === "number")
|
|
164
|
+
cliArgs.push("--max-plies-below", String(args.max_plies_below));
|
|
165
|
+
const stdout = await runFenfindScript("readpgn", cliArgs);
|
|
166
|
+
return parseFenfindJson("readpgn", stdout);
|
|
167
|
+
}
|