@chessceo/mcp 0.49.11 → 0.50.1
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 +173 -182
- package/dist/analysis/cloud.js +182 -0
- package/dist/analysis/deep.js +39 -66
- package/dist/analysis/eval_cache.js +138 -0
- package/dist/analysis/file_handle.js +43 -36
- package/dist/analysis/response.js +190 -128
- package/dist/index.js +96 -111
- package/dist/pgn/exporter.js +13 -11
- package/dist/pgn/mutations.js +18 -5
- package/dist/pgn/parser.js +14 -15
- package/dist/pgn/types.js +2 -0
- package/dist/prep/mutations.js +15 -3
- package/dist/tools.js +76 -101
- package/docs/engine-usage.md +39 -40
- package/docs/pgn-authoring.md +3 -3
- package/docs/summary-authoring.md +1 -1
- package/package.json +1 -1
package/dist/analysis/deep.js
CHANGED
|
@@ -1,22 +1,11 @@
|
|
|
1
|
-
// deep_analyse: async background job for
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
// Concretely: the job fires an unawaited authedRequest to the backend
|
|
10
|
-
// with engines=["stockfish"] + long movetime; the backend's per-engine
|
|
11
|
-
// semaphore lets that hold only the SF slot for the duration. The
|
|
12
|
-
// MCP-side promise resolves when the long HTTP call returns (nginx
|
|
13
|
-
// proxy_read_timeout is bumped to 420s on /api/agent/ to cover 5-min
|
|
14
|
-
// movetime + engine bestmove grace).
|
|
15
|
-
//
|
|
16
|
-
// Extracted from index.ts in v0.44 as part of the file split.
|
|
17
|
-
import { authedRequest } from "../http.js";
|
|
18
|
-
import { analysisToStoredEval, convertCloudSnapshotResponse } from "./response.js";
|
|
19
|
-
import { analyseRouting, resolveFromNodeOrFen, storeEvalOnNode, } from "./file_handle.js";
|
|
1
|
+
// deep_analyse: async background job for ONE long think on ONE position
|
|
2
|
+
// (up to 5 min movetime), on any engine (default Stockfish). Same
|
|
3
|
+
// start / status / cancel shape as auto_evaluate. The long call holds only
|
|
4
|
+
// that engine's slot, so the other engines stay usable through
|
|
5
|
+
// cloud_analyse while it runs. nginx proxy_read_timeout on /api/agent/ is
|
|
6
|
+
// 420s to cover a 5-min think plus the bestmove grace.
|
|
7
|
+
import { analysePositions, convertPositionResult, parseEngines, resultToStoredEval } from "./response.js";
|
|
8
|
+
import { resolveFromNodeOrFen, storeEvals } from "./file_handle.js";
|
|
20
9
|
const deepJobs = new Map();
|
|
21
10
|
const DEEP_JOB_TTL_MS = 15 * 60 * 1000;
|
|
22
11
|
function newDeepJobId() {
|
|
@@ -33,57 +22,43 @@ function reapExpiredDeepJobs() {
|
|
|
33
22
|
}
|
|
34
23
|
export async function deepAnalyseStart(args) {
|
|
35
24
|
reapExpiredDeepJobs();
|
|
25
|
+
const [engine] = parseEngines(args.engine, ["stockfish"]);
|
|
36
26
|
const resolved = await resolveFromNodeOrFen(args);
|
|
37
27
|
const fen = resolved.fen;
|
|
38
28
|
const movetimeMs = typeof args.movetime_ms === "number" ? args.movetime_ms : 60_000;
|
|
39
|
-
// Default 2
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
const
|
|
29
|
+
// Default 2 for Stockfish (it loses strength at higher multipv), 8 for
|
|
30
|
+
// the NN engines (it costs them nothing).
|
|
31
|
+
const multipv = typeof args.multipv === "number" ? args.multipv : engine === "stockfish" ? 2 : 8;
|
|
32
|
+
const opts = { engines: [engine], movetime_ms: movetimeMs, multipv: { [engine]: multipv } };
|
|
33
|
+
if (typeof args.contempt === "number")
|
|
34
|
+
opts.contempt = args.contempt;
|
|
35
|
+
if (typeof args.rental === "string" && args.rental.trim())
|
|
36
|
+
opts.rentals = { [engine]: args.rental.trim() };
|
|
43
37
|
const jobId = newDeepJobId();
|
|
44
38
|
const job = {
|
|
45
39
|
id: jobId,
|
|
46
|
-
|
|
40
|
+
engine,
|
|
41
|
+
opts,
|
|
47
42
|
status: "running",
|
|
48
43
|
fileHandle: resolved.file,
|
|
49
44
|
fen,
|
|
50
45
|
movetimeMs,
|
|
51
|
-
multipv,
|
|
52
46
|
startedAt: Date.now(),
|
|
53
47
|
cancelController: new AbortController(),
|
|
54
48
|
};
|
|
55
49
|
deepJobs.set(jobId, job);
|
|
56
|
-
// Kick off the long HTTP call unawaited — resolves when the backend
|
|
57
|
-
// returns the SF snapshot. authedRequest is a plain fetch under the
|
|
58
|
-
// hood; abort signal flows via cancelController.
|
|
59
50
|
void runDeepJob(job).catch(err => {
|
|
60
51
|
job.status = "error";
|
|
61
52
|
job.error = err instanceof Error ? err.message : String(err);
|
|
62
53
|
job.finishedAt = Date.now();
|
|
63
54
|
});
|
|
64
|
-
return {
|
|
65
|
-
job_id: jobId,
|
|
66
|
-
status: "running",
|
|
67
|
-
movetime_ms: movetimeMs,
|
|
68
|
-
fen,
|
|
69
|
-
};
|
|
55
|
+
return { job_id: jobId, status: "running", engine, movetime_ms: movetimeMs, fen };
|
|
70
56
|
}
|
|
71
57
|
async function runDeepJob(job) {
|
|
72
|
-
const body = {
|
|
73
|
-
fen: job.fen,
|
|
74
|
-
movetime_ms: job.movetimeMs,
|
|
75
|
-
stockfish_multipv: job.multipv,
|
|
76
|
-
engines: ["stockfish"],
|
|
77
|
-
...job.routing,
|
|
78
|
-
};
|
|
79
58
|
let raw;
|
|
80
59
|
try {
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
// job so the caller stops polling; the backend still runs the
|
|
84
|
-
// engine to completion and the result is stored on the job
|
|
85
|
-
// record but flagged cancelled.
|
|
86
|
-
raw = await authedRequest("POST", "/api/agent/cloud-engines/analyse", body);
|
|
60
|
+
// Cancel only marks the job; the backend runs the think to completion.
|
|
61
|
+
[raw] = await analysePositions([{ fen: job.fen }], job.opts);
|
|
87
62
|
}
|
|
88
63
|
catch (err) {
|
|
89
64
|
job.status = "error";
|
|
@@ -91,31 +66,26 @@ async function runDeepJob(job) {
|
|
|
91
66
|
job.finishedAt = Date.now();
|
|
92
67
|
return;
|
|
93
68
|
}
|
|
94
|
-
const converted =
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
job.finishedAt = Date.now();
|
|
104
|
-
// Same node-persistence as cloud_analyse: if the caller anchored on
|
|
105
|
-
// file_id+node_id, store the SF-only eval as the node's ceoEval so
|
|
106
|
-
// quote_engine_eval can cite it later. We build a StoredEval that has
|
|
107
|
-
// only the sf leg — no Lc0 was run.
|
|
108
|
-
if (job.fileHandle && sf) {
|
|
109
|
-
const ev = analysisToStoredEval({ stockfish: sf });
|
|
69
|
+
const converted = raw ? convertPositionResult(raw) : undefined;
|
|
70
|
+
const block = converted?.engines[job.engine];
|
|
71
|
+
job.result = block ?? null;
|
|
72
|
+
if (block?.error)
|
|
73
|
+
job.error = block.error;
|
|
74
|
+
// Merge into the node's ceoEval (other engines' stored reads are kept)
|
|
75
|
+
// and stamp transpositions, same as cloud_analyse.
|
|
76
|
+
if (job.fileHandle && converted && !job.cancelController.signal.aborted) {
|
|
77
|
+
const ev = resultToStoredEval(converted);
|
|
110
78
|
if (ev) {
|
|
111
79
|
try {
|
|
112
|
-
await
|
|
80
|
+
[job.storedOn] = await storeEvals(job.fileHandle.id, [{ fen: job.fen, ev }]);
|
|
113
81
|
}
|
|
114
|
-
catch {
|
|
115
|
-
|
|
82
|
+
catch (err) {
|
|
83
|
+
job.storeError = `analysis done but saving to the file failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
116
84
|
}
|
|
117
85
|
}
|
|
118
86
|
}
|
|
87
|
+
job.status = job.cancelController.signal.aborted ? "cancelled" : block && !block.error ? "done" : "error";
|
|
88
|
+
job.finishedAt = Date.now();
|
|
119
89
|
}
|
|
120
90
|
export function deepAnalyseStatus(args) {
|
|
121
91
|
reapExpiredDeepJobs();
|
|
@@ -134,8 +104,11 @@ export function deepAnalyseStatus(args) {
|
|
|
134
104
|
status: job.status,
|
|
135
105
|
movetime_ms: job.movetimeMs,
|
|
136
106
|
elapsed_ms: (job.finishedAt ?? Date.now()) - job.startedAt,
|
|
107
|
+
engine: job.engine,
|
|
137
108
|
fen: job.fen,
|
|
138
109
|
result: job.result,
|
|
110
|
+
stored_on: job.storedOn,
|
|
111
|
+
store_error: job.storeError,
|
|
139
112
|
error: job.error,
|
|
140
113
|
started_at_ms: job.startedAt,
|
|
141
114
|
finished_at_ms: job.finishedAt,
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Per-user memory of cloud-engine results, so analysis is never lost.
|
|
2
|
+
//
|
|
3
|
+
// Claude often analyses loose FENs or move lines first and adds the moves
|
|
4
|
+
// to a file later. Without this, those nodes land with no [%ceo-eval] and
|
|
5
|
+
// the final pass has to analyse them again. Every result that comes back
|
|
6
|
+
// from the analyse endpoint is remembered here, keyed by user id and
|
|
7
|
+
// position (positionKey, same as transpositions), and every file write
|
|
8
|
+
// (add_move, add_line, apply_mutations, ...) fills nodes whose position is
|
|
9
|
+
// remembered and whose stored eval for that engine is missing or shallower.
|
|
10
|
+
//
|
|
11
|
+
// Keyed by the user id from GET /api/agent/me, not by the token: a hosted
|
|
12
|
+
// OAuth access token rotates hourly, the user id doesn't, and one user's
|
|
13
|
+
// analysed positions (their prep) must never land in another user's file.
|
|
14
|
+
// In memory only: a process restart forgets, which costs a re-analysis,
|
|
15
|
+
// never a wrong eval. Every function here is best-effort and never throws
|
|
16
|
+
// into the write path.
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
18
|
+
import { authedRequest, resolveAuthHeader } from "../http.js";
|
|
19
|
+
import { buildIdIndex, positionKey, resolveNodeId } from "../pgn/paths.js";
|
|
20
|
+
import { setCeoEval } from "../pgn/mutations.js";
|
|
21
|
+
import { STORED_EVAL_KEYS } from "../pgn/types.js";
|
|
22
|
+
const TTL_MS = 24 * 60 * 60 * 1000;
|
|
23
|
+
const MAX_POSITIONS_PER_USER = 20_000;
|
|
24
|
+
const MAX_KNOWN_TOKENS = 1_000;
|
|
25
|
+
const cacheByUser = new Map();
|
|
26
|
+
// sha256(Authorization header) → user id. Never the raw token.
|
|
27
|
+
const userByToken = new Map();
|
|
28
|
+
async function currentUserId() {
|
|
29
|
+
const auth = resolveAuthHeader();
|
|
30
|
+
if (!auth)
|
|
31
|
+
return null;
|
|
32
|
+
const key = createHash("sha256").update(auth).digest("hex");
|
|
33
|
+
let p = userByToken.get(key);
|
|
34
|
+
if (!p) {
|
|
35
|
+
if (userByToken.size >= MAX_KNOWN_TOKENS)
|
|
36
|
+
userByToken.clear();
|
|
37
|
+
p = authedRequest("GET", "/api/agent/me")
|
|
38
|
+
.then(r => {
|
|
39
|
+
const id = r?.userId;
|
|
40
|
+
return typeof id === "string" && id ? id : null;
|
|
41
|
+
})
|
|
42
|
+
.catch(() => null);
|
|
43
|
+
userByToken.set(key, p);
|
|
44
|
+
}
|
|
45
|
+
const id = await p;
|
|
46
|
+
// Don't pin a failure (backend blip, old backend): ask again next time.
|
|
47
|
+
if (!id)
|
|
48
|
+
userByToken.delete(key);
|
|
49
|
+
return id;
|
|
50
|
+
}
|
|
51
|
+
const deeper = (a, b) => (a ?? 0) > (b ?? 0);
|
|
52
|
+
// Remember fresh results. Per engine, a deeper result replaces a shallower
|
|
53
|
+
// one; equal depth takes the newer.
|
|
54
|
+
export async function rememberEvals(entries) {
|
|
55
|
+
try {
|
|
56
|
+
if (entries.length === 0)
|
|
57
|
+
return;
|
|
58
|
+
const user = await currentUserId();
|
|
59
|
+
if (!user)
|
|
60
|
+
return;
|
|
61
|
+
let cache = cacheByUser.get(user);
|
|
62
|
+
if (!cache) {
|
|
63
|
+
cache = new Map();
|
|
64
|
+
cacheByUser.set(user, cache);
|
|
65
|
+
}
|
|
66
|
+
const now = Date.now();
|
|
67
|
+
for (const { fen, ev } of entries) {
|
|
68
|
+
const key = positionKey(fen);
|
|
69
|
+
const merged = { ...(cache.get(key) ?? {}) };
|
|
70
|
+
for (const k of STORED_EVAL_KEYS) {
|
|
71
|
+
const fresh = ev[k];
|
|
72
|
+
if (!fresh)
|
|
73
|
+
continue;
|
|
74
|
+
const old = merged[k];
|
|
75
|
+
const oldLive = old && now - old.at < TTL_MS;
|
|
76
|
+
if (!oldLive || !deeper(old.ev.depth, fresh.depth))
|
|
77
|
+
merged[k] = { ev: fresh, at: now };
|
|
78
|
+
}
|
|
79
|
+
// Re-insert so Map order is least-recently-written first.
|
|
80
|
+
cache.delete(key);
|
|
81
|
+
cache.set(key, merged);
|
|
82
|
+
}
|
|
83
|
+
while (cache.size > MAX_POSITIONS_PER_USER) {
|
|
84
|
+
const oldest = cache.keys().next().value;
|
|
85
|
+
if (oldest === undefined)
|
|
86
|
+
break;
|
|
87
|
+
cache.delete(oldest);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// best-effort
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Fill remembered evals into the file: every node whose position was
|
|
95
|
+
// analysed gets the engines it lacks, or a deeper read than it holds.
|
|
96
|
+
// Returns the file unchanged (and filled = 0) on any problem. `skipIds`
|
|
97
|
+
// are nodes whose eval the caller set or cleared on purpose in this write.
|
|
98
|
+
export async function fillFromCache(file, skipIds = new Set()) {
|
|
99
|
+
try {
|
|
100
|
+
const user = await currentUserId();
|
|
101
|
+
const cache = user ? cacheByUser.get(user) : undefined;
|
|
102
|
+
if (!cache || cache.size === 0)
|
|
103
|
+
return { file, filled: 0 };
|
|
104
|
+
const now = Date.now();
|
|
105
|
+
const patches = [];
|
|
106
|
+
const walk = (node) => {
|
|
107
|
+
if (!skipIds.has(node.id)) {
|
|
108
|
+
const hit = cache.get(positionKey(node.fen));
|
|
109
|
+
if (hit) {
|
|
110
|
+
const patch = {};
|
|
111
|
+
for (const k of STORED_EVAL_KEYS) {
|
|
112
|
+
const c = hit[k];
|
|
113
|
+
if (!c || now - c.at >= TTL_MS)
|
|
114
|
+
continue;
|
|
115
|
+
const stored = node.ceoEval?.[k];
|
|
116
|
+
if (!stored || deeper(c.ev.depth, stored.depth))
|
|
117
|
+
patch[k] = c.ev;
|
|
118
|
+
}
|
|
119
|
+
if (patch.sf || patch.lc0 || patch.human)
|
|
120
|
+
patches.push({ id: node.id, ev: patch });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
for (const c of node.children)
|
|
124
|
+
walk(c);
|
|
125
|
+
};
|
|
126
|
+
walk(file.root);
|
|
127
|
+
if (patches.length === 0)
|
|
128
|
+
return { file, filled: 0 };
|
|
129
|
+
const idIndex = buildIdIndex(file.root);
|
|
130
|
+
let out = file;
|
|
131
|
+
for (const p of patches)
|
|
132
|
+
out = setCeoEval(out, resolveNodeId(idIndex, p.id), p.ev).file;
|
|
133
|
+
return { file: out, filled: patches.length };
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return { file, filled: 0 };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -120,48 +120,55 @@ export function getNodeByPath(root, path) {
|
|
|
120
120
|
}
|
|
121
121
|
return cur;
|
|
122
122
|
}
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
// we silently drop the store rather than fail the analysis the LLM
|
|
129
|
-
// actually asked for. The eval is still returned in the response
|
|
130
|
-
// either way.
|
|
123
|
+
// Merge fresh evals into a file and save once. Each entry is stamped on
|
|
124
|
+
// every node that reaches its position (the frontend's 3-field FEN key:
|
|
125
|
+
// piece placement + side to move + castling), so a transposition is never
|
|
126
|
+
// analysed twice. Merging per engine means a Stockfish-only result keeps a
|
|
127
|
+
// stored human or Lc0 read.
|
|
131
128
|
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
129
|
+
// The file is re-read right before the write, and a version race (someone
|
|
130
|
+
// saved between our GET and PUT) is retried once on a fresh copy; the merge
|
|
131
|
+
// makes re-applying safe. Returns, per entry, the ids it was stamped on
|
|
132
|
+
// (empty if that position is not in the file). Throws if the save fails
|
|
133
|
+
// twice, so callers can report it rather than silently lose evals.
|
|
134
|
+
export async function storeEvals(fileId, entries) {
|
|
135
|
+
let lastErr;
|
|
136
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
137
|
+
const g = await fetchGame(fileId);
|
|
138
|
+
const parsed = parsePGN(g.pgnContent);
|
|
139
|
+
const fenIndex = buildFenIndex(parsed.root);
|
|
140
|
+
const idIndex = buildIdIndex(parsed.root);
|
|
141
|
+
let file = parsed;
|
|
142
|
+
const stamped = [];
|
|
143
|
+
for (const { fen, ev } of entries) {
|
|
144
|
+
const group = fenIndex.get(positionKey(fen)) ?? [];
|
|
145
|
+
const paths = group.map(n => resolveNodeId(idIndex, n.id));
|
|
146
|
+
const step = setCeoEvalMany(file, paths, ev);
|
|
147
|
+
file = step.file;
|
|
148
|
+
stamped.push(step.ids);
|
|
149
|
+
}
|
|
150
|
+
if (stamped.every(ids => ids.length === 0))
|
|
151
|
+
return stamped;
|
|
152
|
+
try {
|
|
153
|
+
await saveGame(fileId, exportPGN(file), g.version ?? 0);
|
|
154
|
+
return stamped;
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
lastErr = err;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
|
|
161
|
+
}
|
|
162
|
+
// Single-node wrapper: stamp `ev` on the handle's node and its transpositions.
|
|
163
|
+
// Returns the ids, the addressed node first; empty on failure (the analysis
|
|
164
|
+
// result is still returned to the caller either way).
|
|
134
165
|
export async function storeEvalOnNode(handle, ev) {
|
|
135
166
|
try {
|
|
136
167
|
const anchor = getNodeByPath(handle.parsedFile.root, handle.nodePath);
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
const group = fenIndex.get(key) ?? [anchor];
|
|
140
|
-
// Resolve every transposed node back to its path. cloneOnPath
|
|
141
|
-
// rebuilds the spine so we need paths, not references — the
|
|
142
|
-
// id index was built against the original tree and every id in
|
|
143
|
-
// `group` exists there.
|
|
144
|
-
const idIndex = handle.idIndex ?? buildIdIndex(handle.parsedFile.root);
|
|
145
|
-
const paths = group.map(n => resolveNodeId(idIndex, n.id));
|
|
146
|
-
const { file: newFile, ids } = setCeoEvalMany(handle.parsedFile, paths, ev);
|
|
147
|
-
const newPgn = exportPGN(newFile);
|
|
148
|
-
await saveGame(handle.id, newPgn, handle.version);
|
|
149
|
-
// Ensure the primary node (the one the LLM addressed) comes first.
|
|
150
|
-
const anchorId = anchor.id;
|
|
151
|
-
return [anchorId, ...ids.filter(x => x !== anchorId)];
|
|
168
|
+
const [ids] = await storeEvals(handle.id, [{ fen: anchor.fen, ev }]);
|
|
169
|
+
return [anchor.id, ...(ids ?? []).filter(x => x !== anchor.id)];
|
|
152
170
|
}
|
|
153
171
|
catch {
|
|
154
172
|
return [];
|
|
155
173
|
}
|
|
156
174
|
}
|
|
157
|
-
// Routing fields for POST /api/agent/cloud-engines/analyse: which rental to
|
|
158
|
-
// run on and which engine legs to run. Omitted fields let the backend pick.
|
|
159
|
-
export function analyseRouting(args) {
|
|
160
|
-
const out = {};
|
|
161
|
-
if (typeof args.contract_id === "string" && args.contract_id.trim()) {
|
|
162
|
-
out.contract_id = args.contract_id.trim();
|
|
163
|
-
}
|
|
164
|
-
if (Array.isArray(args.engines))
|
|
165
|
-
out.engines = args.engines;
|
|
166
|
-
return out;
|
|
167
|
-
}
|