@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.
@@ -1,22 +1,11 @@
1
- // deep_analyse: async background job for a SINGLE long Stockfish think
2
- // on ONE position (up to 5 min movetime). Same start / status / cancel
3
- // shape as auto_evaluate, but different intent — the point is to free
4
- // the tool response path from a 5-minute wait AND to keep the Lc0 slot
5
- // free on the combo so the LLM can keep calling
6
- // `cloud_analyse({engines: ["lc0"]})` for other positions while the
7
- // deep SF think runs.
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 — SF loses meaningful strength at higher multipv, so a
40
- // deep think is best spent on a tight candidate list. Matches the
41
- // cloud_analyse stockfish_multipv default.
42
- const multipv = typeof args.multipv === "number" ? args.multipv : 2;
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
- routing: analyseRouting(args),
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
- // TODO(future): plumb an AbortSignal through authedRequest for
82
- // real mid-flight cancellation. For now, cancel just marks the
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 = convertCloudSnapshotResponse(raw, job.fen);
95
- const sf = converted.stockfish;
96
- if (job.cancelController.signal.aborted) {
97
- job.status = "cancelled";
98
- }
99
- else {
100
- job.status = "done";
101
- }
102
- job.result = sf ?? null;
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 storeEvalOnNode(job.fileHandle, ev);
80
+ [job.storedOn] = await storeEvals(job.fileHandle.id, [{ fen: job.fen, ev }]);
113
81
  }
114
- catch {
115
- // best-effort — the analysis result is what the LLM asked for
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
- // Persist a fresh ceoEval on the node referenced by the file handle
124
- // AND on every other node in the same file that transposes to the
125
- // same position (matches on the frontend's 3-field FEN key: piece
126
- // placement + side to move + castling). Best-effort — if the file
127
- // version raced (another agent saved between our GET and our PUT),
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
- // Return: ids of every node the eval was stamped on (empty on error).
133
- // The primary node's id is always first (if present).
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 key = positionKey(anchor.fen);
138
- const fenIndex = buildFenIndex(handle.parsedFile.root);
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
- }