mcp-databnf 1.1.0 → 2.0.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/index.js CHANGED
@@ -1,13 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
+ import process2 from "process";
4
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
6
 
6
7
  // src/server.ts
7
8
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
9
 
10
+ // src/config.ts
11
+ import process from "process";
12
+
9
13
  // src/version.ts
10
- var PKG_VERSION = "1.1.0";
14
+ var PKG_VERSION = "2.0.0";
11
15
  var REPO_URL = "https://github.com/smeet666/mcp-databnf";
12
16
 
13
17
  // src/config.ts
@@ -20,7 +24,9 @@ var DEFAULT_USER_AGENT = `mcp-databnf/${PKG_VERSION} (+${REPO_URL})`;
20
24
  function createLogger(level) {
21
25
  const rank = LOG_LEVELS.indexOf(level);
22
26
  const write = (at, message) => {
23
- if (rank === 0 || LOG_LEVELS.indexOf(at) > rank) return;
27
+ if (rank === 0 || LOG_LEVELS.indexOf(at) > rank) {
28
+ return;
29
+ }
24
30
  process.stderr.write(`[mcp-databnf] ${at}: ${message}
25
31
  `);
26
32
  };
@@ -35,9 +41,11 @@ function createLogger(level) {
35
41
  }
36
42
  function readInteger(env, name, fallback, min, max) {
37
43
  const raw = env[name];
38
- if (raw === void 0 || raw.trim() === "") return fallback;
44
+ if (raw === void 0 || raw.trim() === "") {
45
+ return fallback;
46
+ }
39
47
  const value = Number(raw);
40
- if (!Number.isFinite(value) || !Number.isInteger(value)) {
48
+ if (!(Number.isFinite(value) && Number.isInteger(value))) {
41
49
  process.stderr.write(
42
50
  `[mcp-databnf] error: ${name}="${raw}" is not a whole number; using ${fallback}.
43
51
  `
@@ -88,7 +96,7 @@ var BnfError = class extends Error {
88
96
  code;
89
97
  details;
90
98
  constructor(code, message, details = {}) {
91
- super(message);
99
+ super(message, details.cause === void 0 ? void 0 : { cause: details.cause });
92
100
  this.name = "BnfError";
93
101
  this.code = code;
94
102
  this.details = details;
@@ -118,7 +126,9 @@ var Cache = class {
118
126
  entries = /* @__PURE__ */ new Map();
119
127
  get(key) {
120
128
  const entry = this.entries.get(key);
121
- if (!entry) return void 0;
129
+ if (!entry) {
130
+ return void 0;
131
+ }
122
132
  if (entry.expiresAt <= Date.now()) {
123
133
  this.entries.delete(key);
124
134
  return void 0;
@@ -128,12 +138,16 @@ var Cache = class {
128
138
  return { value: entry.value, retrievedAt: entry.retrievedAt };
129
139
  }
130
140
  set(key, value, retrievedAt) {
131
- if (this.ttlMs <= 0) return;
141
+ if (this.ttlMs <= 0) {
142
+ return;
143
+ }
132
144
  this.entries.delete(key);
133
145
  this.entries.set(key, { value, expiresAt: Date.now() + this.ttlMs, retrievedAt });
134
146
  while (this.entries.size > this.maxEntries) {
135
147
  const oldest = this.entries.keys().next();
136
- if (oldest.done) break;
148
+ if (oldest.done) {
149
+ break;
150
+ }
137
151
  this.entries.delete(oldest.value);
138
152
  }
139
153
  }
@@ -143,6 +157,9 @@ var Cache = class {
143
157
  };
144
158
 
145
159
  // src/bnf/endpoint.ts
160
+ var TRAILING_DOT = /\.$/;
161
+ var FRAGMENT = /#.*$/;
162
+ var HTTP_SCHEME = /^http:/;
146
163
  var SPARQL_ENDPOINT = "https://data.bnf.fr/sparql";
147
164
  var ALLOWED_HOST = "data.bnf.fr";
148
165
  var GALLICA_HOST = "gallica.bnf.fr";
@@ -150,22 +167,23 @@ function assertRequestable(url) {
150
167
  let parsed;
151
168
  try {
152
169
  parsed = new URL(url);
153
- } catch {
154
- throw refusal(`mcp-databnf refuses to request "${url}": it is not an address.`);
170
+ } catch (cause) {
171
+ throw refusal(`mcp-databnf refuses to request "${url}": it is not an address.`, cause);
155
172
  }
156
- const host = parsed.hostname.toLowerCase().replace(/\.$/, "");
173
+ const host = parsed.hostname.toLowerCase().replace(TRAILING_DOT, "");
157
174
  if (host !== ALLOWED_HOST) {
158
175
  throw refusal(
159
176
  `mcp-databnf requests ${ALLOWED_HOST} and nothing else; "${host}" was refused. Gallica in particular is described from the catalogue and never read.`
160
177
  );
161
178
  }
162
179
  }
163
- var refusal = (message) => new BnfError("network_error", message, {
164
- hint: "This server reads the BnF catalogue at data.bnf.fr. It holds no licence to read the digitised contents, so it does not follow an address that leaves that host."
180
+ var refusal = (message, cause) => new BnfError("network_error", message, {
181
+ hint: "This server reads the BnF catalogue at data.bnf.fr. It holds no licence to read the digitised contents, so it does not follow an address that leaves that host.",
182
+ cause
165
183
  });
166
184
  function isGallicaAddress(address) {
167
185
  try {
168
- const host = new URL(address).hostname.toLowerCase().replace(/\.$/, "");
186
+ const host = new URL(address).hostname.toLowerCase().replace(TRAILING_DOT, "");
169
187
  return host === GALLICA_HOST || host.endsWith(`.${GALLICA_HOST}`);
170
188
  } catch {
171
189
  return false;
@@ -192,21 +210,28 @@ var PREFIXES = {
192
210
  rdafrbr: "http://rdvocab.info/uri/schema/FRBRentitiesRDA/"
193
211
  };
194
212
  var PREFIX_BLOCK = Object.entries(PREFIXES).map(([name, iri]) => `PREFIX ${name}: <${iri}>`).join("\n");
195
- var publicPageFor = (entityIri) => entityIri.replace(/#.*$/, "").replace(/^http:/, "https:");
213
+ var publicPageFor = (entityIri) => entityIri.replace(FRAGMENT, "").replace(HTTP_SCHEME, "https:");
196
214
 
197
215
  // src/bnf/http.ts
216
+ var VIRTUOSO_ERROR = /^Virtuoso\s+(\w+)\s+Error\s+([\s\S]*)$/;
198
217
  var RETRYABLE = /* @__PURE__ */ new Set([408, 425, 429, 500, 502, 503, 504]);
199
218
  var PUSH_BACK = /* @__PURE__ */ new Set([429, 503]);
200
219
  var LONGEST_WAIT_MS = 3e4;
201
220
  var RETRIES_AFTER_SILENCE = 1;
202
221
  var MAX_REDIRECTS = 2;
203
222
  function parseRetryAfter(value, now = Date.now()) {
204
- if (!value) return null;
223
+ if (!value) {
224
+ return null;
225
+ }
205
226
  const trimmed = value.trim();
206
227
  const seconds = Number(trimmed);
207
- if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1e3);
228
+ if (Number.isFinite(seconds) && seconds >= 0) {
229
+ return Math.round(seconds * 1e3);
230
+ }
208
231
  const at = Date.parse(trimmed);
209
- if (Number.isNaN(at)) return null;
232
+ if (Number.isNaN(at)) {
233
+ return null;
234
+ }
210
235
  return Math.max(0, at - now);
211
236
  }
212
237
  function backoffMs(attempt) {
@@ -214,8 +239,10 @@ function backoffMs(attempt) {
214
239
  return base + Math.floor(Math.random() * 500);
215
240
  }
216
241
  function readEngineError(body) {
217
- const match = /^Virtuoso\s+(\w+)\s+Error\s+([\s\S]*)$/.exec(body.trim());
218
- if (!match) return null;
242
+ const match = VIRTUOSO_ERROR.exec(body.trim());
243
+ if (!match) {
244
+ return null;
245
+ }
219
246
  const state = match[1] ?? "";
220
247
  const text2 = (match[2] ?? "").split("\n")[0]?.trim() ?? "";
221
248
  return { kind: state === "37000" ? "compile" : "runtime", text: text2 };
@@ -257,6 +284,78 @@ async function postOnce(options, signal) {
257
284
  url: SPARQL_ENDPOINT
258
285
  });
259
286
  }
287
+ function readRefusal(response, attempt, maxRetries) {
288
+ const pushBack = RETRYABLE.has(response.status);
289
+ if (PUSH_BACK.has(response.status)) {
290
+ const asked = parseRetryAfter(response.headers.get("retry-after"));
291
+ if (asked !== null && asked > LONGEST_WAIT_MS) {
292
+ return {
293
+ kind: "refused",
294
+ pushBack,
295
+ error: rateLimited(
296
+ `data.bnf.fr asked this client to wait ${Math.round(asked / 1e3)} seconds (HTTP ${response.status}).`,
297
+ { url: SPARQL_ENDPOINT, status: response.status }
298
+ )
299
+ };
300
+ }
301
+ if (attempt >= maxRetries) {
302
+ return {
303
+ kind: "refused",
304
+ pushBack,
305
+ error: rateLimited(
306
+ `data.bnf.fr asked this client to slow down (HTTP ${response.status}).`,
307
+ {
308
+ url: SPARQL_ENDPOINT,
309
+ status: response.status
310
+ }
311
+ )
312
+ };
313
+ }
314
+ return { kind: "again", pushBack, waitMs: asked ?? backoffMs(attempt) };
315
+ }
316
+ if (RETRYABLE.has(response.status) && attempt < maxRetries) {
317
+ return { kind: "again", pushBack, waitMs: backoffMs(attempt) };
318
+ }
319
+ if (response.status === 400 || response.status === 422) {
320
+ const engine = readEngineError(response.body);
321
+ return {
322
+ kind: "refused",
323
+ pushBack,
324
+ error: invalidInput(
325
+ `data.bnf.fr would not run this query${engine?.text ? `: ${engine.text}` : "."}`,
326
+ "Try fewer or plainer words. If this keeps happening with ordinary input, please report it."
327
+ )
328
+ };
329
+ }
330
+ return {
331
+ kind: "refused",
332
+ pushBack,
333
+ error: networkError(`data.bnf.fr answered HTTP ${response.status}.`, {
334
+ url: SPARQL_ENDPOINT,
335
+ status: response.status
336
+ })
337
+ };
338
+ }
339
+ function readFailure(error, attempts) {
340
+ const { attempt, maxRetries, timeoutMs } = attempts;
341
+ if (error instanceof Error && error.name === "BnfError") {
342
+ throw error;
343
+ }
344
+ if (error instanceof Error && error.name === "AbortError") {
345
+ if (attempt >= Math.min(maxRetries, RETRIES_AFTER_SILENCE)) {
346
+ throw timeout(
347
+ `No answer from data.bnf.fr within ${timeoutMs}ms. A query spanning the whole catalogue can take longer than the service will spend on it.`,
348
+ { url: SPARQL_ENDPOINT }
349
+ );
350
+ }
351
+ return error;
352
+ }
353
+ const failure = error instanceof Error ? error : new Error(String(error));
354
+ if (attempt >= maxRetries) {
355
+ throw networkError(`Could not reach data.bnf.fr: ${failure.message}`, { url: SPARQL_ENDPOINT });
356
+ }
357
+ return failure;
358
+ }
260
359
  async function runQuery(options) {
261
360
  const { timeoutMs, maxRetries, limiter, logger } = options;
262
361
  let lastError = null;
@@ -264,7 +363,8 @@ async function runQuery(options) {
264
363
  for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
265
364
  if (askedWaitMs > 0) {
266
365
  logger.debug(`waiting ${askedWaitMs}ms, as asked`);
267
- await new Promise((resolve) => setTimeout(resolve, askedWaitMs));
366
+ const asked = askedWaitMs;
367
+ await new Promise((resolve) => setTimeout(resolve, asked));
268
368
  askedWaitMs = 0;
269
369
  }
270
370
  await limiter.beforeRequest();
@@ -284,66 +384,19 @@ async function runQuery(options) {
284
384
  limiter.succeeded();
285
385
  return parsed;
286
386
  }
287
- if (RETRYABLE.has(response.status)) limiter.pushBack();
288
- if (PUSH_BACK.has(response.status)) {
289
- const asked = parseRetryAfter(response.headers.get("retry-after"));
290
- if (asked !== null && asked > LONGEST_WAIT_MS) {
291
- throw rateLimited(
292
- `data.bnf.fr asked this client to wait ${Math.round(asked / 1e3)} seconds (HTTP ${response.status}).`,
293
- { url: SPARQL_ENDPOINT, status: response.status }
294
- );
295
- }
296
- if (attempt >= maxRetries) {
297
- throw rateLimited(
298
- `data.bnf.fr asked this client to slow down (HTTP ${response.status}).`,
299
- {
300
- url: SPARQL_ENDPOINT,
301
- status: response.status
302
- }
303
- );
304
- }
305
- askedWaitMs = asked ?? backoffMs(attempt);
306
- lastError = new Error(`HTTP ${response.status}`);
307
- continue;
308
- }
309
- if (RETRYABLE.has(response.status) && attempt < maxRetries) {
310
- lastError = new Error(`HTTP ${response.status}`);
311
- askedWaitMs = backoffMs(attempt);
312
- continue;
387
+ const verdict = readRefusal(response, attempt, maxRetries);
388
+ if (verdict.pushBack) {
389
+ limiter.pushBack();
313
390
  }
314
- if (response.status === 400 || response.status === 422) {
315
- const engine = readEngineError(response.body);
316
- throw invalidInput(
317
- `data.bnf.fr would not run this query${engine?.text ? `: ${engine.text}` : "."}`,
318
- "Try fewer or plainer words. If this keeps happening with ordinary input, please report it."
319
- );
391
+ if (verdict.kind === "refused") {
392
+ throw verdict.error;
320
393
  }
321
- throw networkError(`data.bnf.fr answered HTTP ${response.status}.`, {
322
- url: SPARQL_ENDPOINT,
323
- status: response.status
324
- });
394
+ askedWaitMs = verdict.waitMs;
395
+ lastError = new Error(`HTTP ${response.status}`);
325
396
  } catch (error) {
326
397
  clearTimeout(deadline);
327
- if (error instanceof Error && error.name === "BnfError") throw error;
328
- if (error instanceof Error && error.name === "AbortError") {
329
- lastError = error;
330
- if (attempt >= Math.min(maxRetries, RETRIES_AFTER_SILENCE)) {
331
- throw timeout(
332
- `No answer from data.bnf.fr within ${timeoutMs}ms. A query spanning the whole catalogue can take longer than the service will spend on it.`,
333
- { url: SPARQL_ENDPOINT }
334
- );
335
- }
336
- askedWaitMs = backoffMs(attempt);
337
- continue;
338
- }
339
- lastError = error instanceof Error ? error : new Error(String(error));
340
- if (attempt >= maxRetries) {
341
- throw networkError(`Could not reach data.bnf.fr: ${lastError.message}`, {
342
- url: SPARQL_ENDPOINT
343
- });
344
- }
398
+ lastError = readFailure(error, { attempt, maxRetries, timeoutMs });
345
399
  askedWaitMs = backoffMs(attempt);
346
- continue;
347
400
  } finally {
348
401
  clearTimeout(deadline);
349
402
  }
@@ -377,9 +430,10 @@ function parseResults(body) {
377
430
  let payload;
378
431
  try {
379
432
  payload = JSON.parse(body);
380
- } catch {
433
+ } catch (cause) {
381
434
  throw parseFailure("data.bnf.fr answered with something that is not JSON.", {
382
- url: SPARQL_ENDPOINT
435
+ url: SPARQL_ENDPOINT,
436
+ cause
383
437
  });
384
438
  }
385
439
  if (typeof payload !== "object" || payload === null || !("results" in payload) || typeof payload.results !== "object" || !Array.isArray(payload.results?.bindings)) {
@@ -391,6 +445,16 @@ function parseResults(body) {
391
445
  }
392
446
 
393
447
  // src/bnf/sparql.ts
448
+ var KEPT_CHARACTER = /[\p{L}\p{N}'\-\s]/u;
449
+ var TYPOGRAPHIC_APOSTROPHE = /[‘’ʼ]/u;
450
+ var LEADING_JOINERS = /^['-]+/u;
451
+ var TRAILING_JOINERS = /['-]+$/u;
452
+ var JOINERS = /['-]+/u;
453
+ var ARK_IN_INPUT = /(?:^|\/)ark:\/12148\/(c[bc][0-9a-z]+)/i;
454
+ var LEADING_HASH = /^#/;
455
+ var FRAGMENT_OR_QUERY = /[#?]/;
456
+ var TEMP_WORK_IN_INPUT = /temp-work\/([0-9a-f]{32})/i;
457
+ var GALLICA_ARK = /ark:\/12148\/([0-9a-z]+)/i;
394
458
  function escapeLiteral(value) {
395
459
  return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, "");
396
460
  }
@@ -402,15 +466,21 @@ function withoutInvisibles(input) {
402
466
  function invisibleCharacters(input) {
403
467
  const found = [];
404
468
  for (const character of input.match(INVISIBLE) ?? []) {
405
- if (!found.includes(character)) found.push(character);
469
+ if (!found.includes(character)) {
470
+ found.push(character);
471
+ }
406
472
  }
407
473
  return found;
408
474
  }
409
475
  function charactersSetAside(input) {
410
476
  const found = [];
411
477
  for (const character of withoutInvisibles(input).normalize("NFC")) {
412
- if (/[\p{L}\p{N}'\-\s]/u.test(character) || /[‘’ʼ]/u.test(character)) continue;
413
- if (!found.includes(character)) found.push(character);
478
+ if (KEPT_CHARACTER.test(character) || TYPOGRAPHIC_APOSTROPHE.test(character)) {
479
+ continue;
480
+ }
481
+ if (!found.includes(character)) {
482
+ found.push(character);
483
+ }
414
484
  }
415
485
  return found;
416
486
  }
@@ -427,18 +497,24 @@ function toSearchWords(input) {
427
497
  const cleaned = withoutInvisibles(input).normalize("NFC").replace(/[‘’ʼ]/gu, "'").replace(/[^\p{L}\p{N}'-]+/gu, " ");
428
498
  const words = [];
429
499
  for (const raw of cleaned.split(" ")) {
430
- const word = raw.replace(/^['-]+/u, "").replace(/['-]+$/u, "");
431
- if (word === "") continue;
500
+ const word = raw.replace(LEADING_JOINERS, "").replace(TRAILING_JOINERS, "");
501
+ if (word === "") {
502
+ continue;
503
+ }
432
504
  words.push(word.length > 60 ? word.slice(0, 60) : word);
433
- if (words.length >= 12) break;
505
+ if (words.length >= 12) {
506
+ break;
507
+ }
434
508
  }
435
509
  return words;
436
510
  }
437
511
  function toIndexTerms(words) {
438
512
  const terms = [];
439
513
  for (const word of words) {
440
- for (const piece of word.split(/['-]+/u)) {
441
- if (piece !== "" && !terms.includes(piece)) terms.push(piece);
514
+ for (const piece of word.split(JOINERS)) {
515
+ if (piece !== "" && !terms.includes(piece)) {
516
+ terms.push(piece);
517
+ }
442
518
  }
443
519
  }
444
520
  return terms;
@@ -460,8 +536,8 @@ function parseEntityId(input) {
460
536
  if (trimmed === "") {
461
537
  throw invalidInput("An identifier is required.");
462
538
  }
463
- const arkMatch = /(?:^|\/)ark:\/12148\/(c[bc][0-9a-z]+)/i.exec(trimmed);
464
- const bare = arkMatch ? arkMatch[1] : trimmed.replace(/^#/, "").split(/[#?]/)[0];
539
+ const arkMatch = ARK_IN_INPUT.exec(trimmed);
540
+ const bare = arkMatch ? arkMatch[1] : trimmed.replace(LEADING_HASH, "").split(FRAGMENT_OR_QUERY)[0];
465
541
  const candidate = (bare ?? "").toLowerCase();
466
542
  if (ARK_ID.test(candidate)) {
467
543
  return {
@@ -471,7 +547,7 @@ function parseEntityId(input) {
471
547
  pageUrl: `https://data.bnf.fr/ark:/12148/${candidate}`
472
548
  };
473
549
  }
474
- const tempMatch = /temp-work\/([0-9a-f]{32})/i.exec(trimmed);
550
+ const tempMatch = TEMP_WORK_IN_INPUT.exec(trimmed);
475
551
  const digest = (tempMatch?.[1] ?? candidate).toLowerCase();
476
552
  if (TEMP_WORK_ID.test(digest)) {
477
553
  return {
@@ -492,7 +568,7 @@ function hintFor(input) {
492
568
  const name = (page2[2] ?? "").replace(/_/g, " ");
493
569
  return `That is the readable address of a data.bnf.fr page, and ${page2[1]} in it is the record's FRBNF number rather than the identifier these tools take. Search for "${name}" with search_authors or search_works, and use the identifier the row carries.`;
494
570
  }
495
- const gallicaArk = /ark:\/12148\/([0-9a-z]+)/i.exec(input)?.[1] ?? input;
571
+ const gallicaArk = GALLICA_ARK.exec(input)?.[1] ?? input;
496
572
  if (GALLICA_DOCUMENT.test(gallicaArk)) {
497
573
  return "That is a Gallica identifier: it names a digitised document rather than a record in the catalogue, and these tools read the catalogue. Search for the work with search_works, then list_editions will give you the Gallica link alongside the edition it belongs to.";
498
574
  }
@@ -500,7 +576,7 @@ function hintFor(input) {
500
576
  }
501
577
  var iriOf = (id) => `<${id.iri}>`;
502
578
  function boundedInteger(value, min, max, name) {
503
- if (!Number.isFinite(value) || !Number.isInteger(value)) {
579
+ if (!(Number.isFinite(value) && Number.isInteger(value))) {
504
580
  throw invalidInput(`'${name}' has to be a whole number.`);
505
581
  }
506
582
  if (value < min || value > max) {
@@ -721,6 +797,14 @@ LIMIT 20`);
721
797
  }
722
798
 
723
799
  // src/bnf/parse.ts
800
+ var ARK_IN_IRI = /ark:\/12148\/(c[bc][0-9a-z]+)/i;
801
+ var TEMP_WORK_IN_IRI = /temp-work\/([0-9a-f]{32})/i;
802
+ var TRAILING_SEPARATOR = /[/#]$/;
803
+ var PATH_SEPARATOR = /[/#]/;
804
+ var ARK_SEGMENT = /ark:\/12148\/([^/?#]+)/;
805
+ var QUERY_START = /[?#]/;
806
+ var LEADING_DOTS = /^[./]+/;
807
+ var TRAILING_SLASHES = /\/+$/;
724
808
  var P = {
725
809
  type: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
726
810
  label: "http://www.w3.org/2000/01/rdf-schema#label",
@@ -776,36 +860,50 @@ var ALIGNMENT_HOSTS = [
776
860
  { host: "catalogue.bnf.fr", name: "catalogue" }
777
861
  ];
778
862
  var text = (term) => {
779
- if (!term) return null;
863
+ if (!term) {
864
+ return null;
865
+ }
780
866
  const value = term.value.trim();
781
867
  return value === "" ? null : value;
782
868
  };
783
869
  var integer = (term) => {
784
870
  const raw = text(term);
785
- if (raw === null) return null;
871
+ if (raw === null) {
872
+ return null;
873
+ }
786
874
  const value = Number(raw);
787
875
  return Number.isInteger(value) ? value : null;
788
876
  };
789
877
  function idFromIri(iri) {
790
- const ark = /ark:\/12148\/(c[bc][0-9a-z]+)/i.exec(iri);
791
- if (ark?.[1]) return ark[1].toLowerCase();
792
- const temp = /temp-work\/([0-9a-f]{32})/i.exec(iri);
793
- if (temp?.[1]) return `temp-work/${temp[1].toLowerCase()}`;
878
+ const ark = ARK_IN_IRI.exec(iri);
879
+ if (ark?.[1]) {
880
+ return ark[1].toLowerCase();
881
+ }
882
+ const temp = TEMP_WORK_IN_IRI.exec(iri);
883
+ if (temp?.[1]) {
884
+ return `temp-work/${temp[1].toLowerCase()}`;
885
+ }
794
886
  return null;
795
887
  }
796
- var vocabularyTerm = (iri) => decodeURIComponent(iri.replace(/[/#]$/, "").split(/[/#]/).pop() ?? iri);
888
+ var vocabularyTerm = (iri) => decodeURIComponent(iri.replace(TRAILING_SEPARATOR, "").split(PATH_SEPARATOR).pop() ?? iri);
797
889
  function toDigitisedLink(address, role, fromId, fromTitle) {
798
- if (!address || !isGallicaAddress(address)) return null;
799
- const segment = /ark:\/12148\/([^/?#]+)/.exec(address)?.[1] ?? null;
800
- if (!segment) return null;
890
+ if (!(address && isGallicaAddress(address))) {
891
+ return null;
892
+ }
893
+ const segment = ARK_SEGMENT.exec(address)?.[1] ?? null;
894
+ if (!segment) {
895
+ return null;
896
+ }
801
897
  const ark = segment.split(".")[0] ?? segment;
802
- if (ark === "") return null;
898
+ if (ark === "") {
899
+ return null;
900
+ }
803
901
  return { ark, url: address, rendering: renderingOf(address, ark), role, fromId, fromTitle };
804
902
  }
805
903
  function renderingOf(address, ark) {
806
- const path = address.split(/[?#]/)[0] ?? address;
904
+ const path = address.split(QUERY_START)[0] ?? address;
807
905
  const after = path.slice(path.indexOf(`ark:/12148/${ark}`) + `ark:/12148/${ark}`.length);
808
- const rendering = after.replace(/^[./]+/, "").replace(/\/+$/, "");
906
+ const rendering = after.replace(LEADING_DOTS, "").replace(TRAILING_SLASHES, "");
809
907
  return rendering === "" ? null : rendering;
810
908
  }
811
909
  function page(rows, limit, skipped) {
@@ -815,9 +913,13 @@ function page(rows, limit, skipped) {
815
913
  function readWindowOccupancy(results) {
816
914
  for (const row of results.results.bindings) {
817
915
  const raw = text(row.windowRows);
818
- if (raw === null) continue;
916
+ if (raw === null) {
917
+ continue;
918
+ }
819
919
  const count = Number(raw);
820
- if (Number.isInteger(count)) return count >= TEXT_WINDOW;
920
+ if (Number.isInteger(count)) {
921
+ return count >= TEXT_WINDOW;
922
+ }
821
923
  }
822
924
  return null;
823
925
  }
@@ -826,10 +928,15 @@ function byPredicate(results) {
826
928
  const index = /* @__PURE__ */ new Map();
827
929
  for (const row of results.results.bindings) {
828
930
  const predicate = text(row.p);
829
- if (predicate === null) continue;
931
+ if (predicate === null) {
932
+ continue;
933
+ }
830
934
  const bucket = index.get(predicate);
831
- if (bucket) bucket.push(row);
832
- else index.set(predicate, [row]);
935
+ if (bucket) {
936
+ bucket.push(row);
937
+ } else {
938
+ index.set(predicate, [row]);
939
+ }
833
940
  }
834
941
  return index;
835
942
  }
@@ -838,7 +945,9 @@ var allValues = (index, predicate) => {
838
945
  const seen = /* @__PURE__ */ new Set();
839
946
  for (const row of index.get(predicate) ?? []) {
840
947
  const value = text(row.o);
841
- if (value !== null) seen.add(value);
948
+ if (value !== null) {
949
+ seen.add(value);
950
+ }
842
951
  }
843
952
  return [...seen];
844
953
  };
@@ -849,8 +958,12 @@ function alignments(index, selfId) {
849
958
  const found = {};
850
959
  for (const predicate of [P.sameAs, P.exactMatch, P.closeMatch, P.seeAlso]) {
851
960
  for (const address of allValues(index, predicate)) {
852
- if (!address.startsWith("http")) continue;
853
- if (selfId && address.includes(selfId)) continue;
961
+ if (!address.startsWith("http")) {
962
+ continue;
963
+ }
964
+ if (selfId && address.includes(selfId)) {
965
+ continue;
966
+ }
854
967
  let host;
855
968
  try {
856
969
  host = new URL(address).hostname.toLowerCase();
@@ -860,8 +973,12 @@ function alignments(index, selfId) {
860
973
  const known = ALIGNMENT_HOSTS.find(
861
974
  (entry) => host === entry.host || host.endsWith(`.${entry.host}`)
862
975
  );
863
- if (!known) continue;
864
- (found[known.name] ??= /* @__PURE__ */ new Set()).add(address);
976
+ if (!known) {
977
+ continue;
978
+ }
979
+ const addresses = found[known.name] ?? /* @__PURE__ */ new Set();
980
+ found[known.name] = addresses;
981
+ addresses.add(address);
865
982
  }
866
983
  }
867
984
  return Object.fromEntries(Object.entries(found).map(([name, set]) => [name, [...set]]));
@@ -876,14 +993,18 @@ function toAuthorSummaries(results, limit) {
876
993
  const seen = /* @__PURE__ */ new Set();
877
994
  let skipped = 0;
878
995
  for (const row of results.results.bindings) {
879
- if (isOccupancyRow(row, "person")) continue;
996
+ if (isOccupancyRow(row, "person")) {
997
+ continue;
998
+ }
880
999
  const iri = text(row.person);
881
1000
  const id = iri === null ? null : idFromIri(iri);
882
1001
  if (iri === null || id === null) {
883
1002
  skipped += 1;
884
1003
  continue;
885
1004
  }
886
- if (seen.has(id)) continue;
1005
+ if (seen.has(id)) {
1006
+ continue;
1007
+ }
887
1008
  seen.add(id);
888
1009
  rows.push({
889
1010
  id,
@@ -904,18 +1025,24 @@ function toAuthorDetail(results, id, pageUrl) {
904
1025
  const seenLabels = /* @__PURE__ */ new Set();
905
1026
  for (const row of index.get(P.altLabel) ?? []) {
906
1027
  const label = text(row.o);
907
- if (label === null || seenLabels.has(label)) continue;
1028
+ if (label === null || seenLabels.has(label)) {
1029
+ continue;
1030
+ }
908
1031
  seenLabels.add(label);
909
1032
  otherNames.push({ label, language: text(row.lang) });
910
1033
  }
911
1034
  const sameAs = alignments(index, id);
912
1035
  const isni = isniAddresses(index);
913
- if (isni.length > 0) sameAs.isni = [.../* @__PURE__ */ new Set([...sameAs.isni ?? [], ...isni])];
1036
+ if (isni.length > 0) {
1037
+ sameAs.isni = [.../* @__PURE__ */ new Set([...sameAs.isni ?? [], ...isni])];
1038
+ }
914
1039
  const catalogueUrl = allValues(index, P.seeAlso).find((address) => address.includes("catalogue.bnf.fr")) ?? null;
915
1040
  const depictions = [];
916
1041
  for (const address of allValues(index, P.depiction)) {
917
1042
  const link = toDigitisedLink(address, "depiction", id, null);
918
- if (link) depictions.push(link);
1043
+ if (link) {
1044
+ depictions.push(link);
1045
+ }
919
1046
  }
920
1047
  return {
921
1048
  id,
@@ -949,8 +1076,12 @@ function toAuthorDetail(results, id, pageUrl) {
949
1076
  function readStatus(statement, iri) {
950
1077
  if (statement !== null) {
951
1078
  const said = statement.toLowerCase();
952
- if (said.includes("provisional")) return "provisional";
953
- if (said.includes("established")) return "established";
1079
+ if (said.includes("provisional")) {
1080
+ return "provisional";
1081
+ }
1082
+ if (said.includes("established")) {
1083
+ return "established";
1084
+ }
954
1085
  }
955
1086
  return iri.includes("/temp-work/") ? "provisional" : "established";
956
1087
  }
@@ -959,7 +1090,9 @@ function toWorkSummaries(results, limit) {
959
1090
  const byId = /* @__PURE__ */ new Map();
960
1091
  let skipped = 0;
961
1092
  for (const row of results.results.bindings) {
962
- if (isOccupancyRow(row, "work")) continue;
1093
+ if (isOccupancyRow(row, "work")) {
1094
+ continue;
1095
+ }
963
1096
  const iri = text(row.work);
964
1097
  const id = iri === null ? null : idFromIri(iri);
965
1098
  if (iri === null || id === null) {
@@ -987,7 +1120,7 @@ function toWorkSummaries(results, limit) {
987
1120
  }
988
1121
  return {
989
1122
  ...page(
990
- order.map((id) => byId.get(id)),
1123
+ order.map((id) => byId.get(id)).filter((one) => one !== void 0),
991
1124
  limit,
992
1125
  skipped
993
1126
  ),
@@ -1020,13 +1153,15 @@ function toAuthoredWorks(results, limit) {
1020
1153
  order.push(id);
1021
1154
  }
1022
1155
  const form = text(row.form);
1023
- if (form !== null && form.startsWith("http")) {
1156
+ if (form?.startsWith("http")) {
1024
1157
  const term = vocabularyTerm(form);
1025
- if (!work.forms.includes(term)) work.forms.push(term);
1158
+ if (!work.forms.includes(term)) {
1159
+ work.forms.push(term);
1160
+ }
1026
1161
  }
1027
1162
  }
1028
1163
  return page(
1029
- order.map((id) => byId.get(id)),
1164
+ order.map((id) => byId.get(id)).filter((one) => one !== void 0),
1030
1165
  limit,
1031
1166
  skipped
1032
1167
  );
@@ -1039,13 +1174,17 @@ function toWorkDetail(results, id, iri, pageUrl) {
1039
1174
  for (const row of index.get(P.creator) ?? []) {
1040
1175
  const creatorIri = text(row.o);
1041
1176
  const creatorId = creatorIri ? idFromIri(creatorIri) : null;
1042
- if (!creatorId || creators.some((c) => c.id === creatorId)) continue;
1177
+ if (!creatorId || creators.some((c) => c.id === creatorId)) {
1178
+ continue;
1179
+ }
1043
1180
  creators.push({ id: creatorId, name: text(row.name) });
1044
1181
  }
1045
1182
  const depictions = [];
1046
1183
  for (const address of allValues(index, P.depiction)) {
1047
1184
  const link = toDigitisedLink(address, "depiction", id, null);
1048
- if (link) depictions.push(link);
1185
+ if (link) {
1186
+ depictions.push(link);
1187
+ }
1049
1188
  }
1050
1189
  const statusStatement = firstValue(index, P.statusOfIdentification);
1051
1190
  const catalogueUrl = allValues(index, P.seeAlso).find((address) => address.includes("catalogue.bnf.fr")) ?? null;
@@ -1115,7 +1254,7 @@ function toEditions(results, limit) {
1115
1254
  }
1116
1255
  }
1117
1256
  return page(
1118
- order.map((id) => byId.get(id)),
1257
+ order.map((id) => byId.get(id)).filter((one) => one !== void 0),
1119
1258
  limit,
1120
1259
  skipped
1121
1260
  );
@@ -1134,7 +1273,9 @@ function toDigitisedLinks(results, limit) {
1134
1273
  ["depiction", "depiction"]
1135
1274
  ]) {
1136
1275
  const link = toDigitisedLink(text(row[column]), role, fromId, title);
1137
- if (!link || seen.has(link.url)) continue;
1276
+ if (!link || seen.has(link.url)) {
1277
+ continue;
1278
+ }
1138
1279
  seen.add(link.url);
1139
1280
  links.push(link);
1140
1281
  }
@@ -1145,7 +1286,9 @@ function toTypes(results) {
1145
1286
  const types = /* @__PURE__ */ new Set();
1146
1287
  for (const row of results.results.bindings) {
1147
1288
  const iri = text(row.type);
1148
- if (iri !== null) types.add(iri);
1289
+ if (iri !== null) {
1290
+ types.add(iri);
1291
+ }
1149
1292
  }
1150
1293
  return [...types];
1151
1294
  }
@@ -1186,7 +1329,9 @@ var RateLimiter = class {
1186
1329
  async beforeRequest() {
1187
1330
  const elapsed = Date.now() - this.lastStartedAt;
1188
1331
  const wait = Math.min(this.intervalMs, this.intervalMs - elapsed);
1189
- if (wait > 0) await sleep(wait);
1332
+ if (wait > 0) {
1333
+ await sleep(wait);
1334
+ }
1190
1335
  this.lastStartedAt = Date.now();
1191
1336
  }
1192
1337
  /** Called when the service asks for room: double the gap, up to the ceiling. */
@@ -1199,9 +1344,13 @@ var RateLimiter = class {
1199
1344
  * response after a rough patch does not undo the caution that earned it.
1200
1345
  */
1201
1346
  succeeded() {
1202
- if (this.intervalMs === this.baseIntervalMs) return;
1347
+ if (this.intervalMs === this.baseIntervalMs) {
1348
+ return;
1349
+ }
1203
1350
  this.calmStreak += 1;
1204
- if (this.calmStreak < 3) return;
1351
+ if (this.calmStreak < 3) {
1352
+ return;
1353
+ }
1205
1354
  this.calmStreak = 0;
1206
1355
  this.intervalMs = Math.max(this.baseIntervalMs, Math.round(this.intervalMs / 2));
1207
1356
  }
@@ -1220,7 +1369,9 @@ function withGuarantees(config) {
1220
1369
  const defaults = loadConfig({});
1221
1370
  const bounded = (value, fallback, min, max) => {
1222
1371
  const parsed = Number(value);
1223
- if (!Number.isFinite(parsed)) return fallback;
1372
+ if (!Number.isFinite(parsed)) {
1373
+ return fallback;
1374
+ }
1224
1375
  return Math.min(max, Math.max(min, parsed));
1225
1376
  };
1226
1377
  const claimed = typeof config.userAgent === "string" ? config.userAgent.trim() : "";
@@ -1271,9 +1422,6 @@ var BnfClient = class {
1271
1422
  return this.config.timeoutMs;
1272
1423
  }
1273
1424
  /** The one address this client sends anything to. */
1274
- get endpoint() {
1275
- return SPARQL_ENDPOINT;
1276
- }
1277
1425
  /**
1278
1426
  * `cacheKey` is stated rather than taken from the query, because two reads of
1279
1427
  * one query can produce different values: a list cut to five rows is not the
@@ -1317,32 +1465,7 @@ var BnfClient = class {
1317
1465
  return { data, cached: false, retrievedAt: new Date(retrievedAt).toISOString() };
1318
1466
  }
1319
1467
  /** Read an identifier a caller wrote, refusing anything this dataset cannot address. */
1320
- identify(input) {
1321
- return parseEntityId(input);
1322
- }
1323
1468
  /** The words a search will actually look for, which a tool reports back. */
1324
- words(input) {
1325
- return toSearchWords(input);
1326
- }
1327
- /**
1328
- * The terms the index requires, which is what a row's presence rests on.
1329
- *
1330
- * The index splits inside a word at an apostrophe and at a hyphen, so a word
1331
- * a caller wrote as one can reach it as two, each required on its own.
1332
- */
1333
- indexTerms(words) {
1334
- return toIndexTerms(words);
1335
- }
1336
- /**
1337
- * How a piece of caller text became the terms the search was run with.
1338
- *
1339
- * A tool reports the reading back, so the same reading has to be the one the
1340
- * query was built from: the words, the terms the index makes of them, and
1341
- * the characters that reached neither.
1342
- */
1343
- searchReading(input) {
1344
- return readSearchText(input);
1345
- }
1346
1469
  searchAuthors(name, limit, offset) {
1347
1470
  const words = toSearchWords(name);
1348
1471
  const query = searchAuthorsQuery(words, limit, offset);
@@ -1457,11 +1580,24 @@ import { z as z3 } from "zod";
1457
1580
  // src/tools/arguments.ts
1458
1581
  import { z } from "zod";
1459
1582
  var INVALID_INPUT = "invalid_input";
1583
+ var defaultMessage = z.core.locales.en().localeError;
1460
1584
  function strictInput(shape) {
1461
1585
  const declared = Object.keys(shape);
1462
- return z.strictObject(shape, {
1463
- error: (issue) => issue.code === "unrecognized_keys" ? unknownArgumentMessage(issue.keys, declared) : void 0
1464
- });
1586
+ const refuse = (issue) => issue.code === "unrecognized_keys" ? unknownArgumentMessage(issue.keys, declared) : `[${INVALID_INPUT}] ${defaultMessage(issue)}`;
1587
+ for (const argument of Object.values(shape)) {
1588
+ carryTheCode(argument, refuse);
1589
+ }
1590
+ return z.strictObject(shape, { error: refuse });
1591
+ }
1592
+ function carryTheCode(schema, refuse) {
1593
+ const definition = schema._zod.def;
1594
+ definition.error = refuse;
1595
+ for (const check of definition.checks ?? []) {
1596
+ check._zod.def.error = refuse;
1597
+ }
1598
+ if (definition.innerType) {
1599
+ carryTheCode(definition.innerType, refuse);
1600
+ }
1465
1601
  }
1466
1602
  function unknownArgumentMessage(keys, declared) {
1467
1603
  const named = keys.map((key) => {
@@ -1473,15 +1609,21 @@ function unknownArgumentMessage(keys, declared) {
1473
1609
  function nearestArgument(key, declared) {
1474
1610
  const flatten = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, "");
1475
1611
  const flat = flatten(key);
1476
- if (flat.length === 0) return void 0;
1612
+ if (flat.length === 0) {
1613
+ return void 0;
1614
+ }
1477
1615
  const sameName = declared.find((name) => flatten(name) === flat);
1478
- if (sameName) return sameName;
1616
+ if (sameName) {
1617
+ return sameName;
1618
+ }
1479
1619
  const overlapping = declared.find((name) => {
1480
1620
  const other = flatten(name);
1481
1621
  const [shorter, longer] = other.length < flat.length ? [other, flat] : [flat, other];
1482
1622
  return shorter.length >= 3 && (longer.startsWith(shorter) || longer.endsWith(shorter));
1483
1623
  });
1484
- if (overlapping) return overlapping;
1624
+ if (overlapping) {
1625
+ return overlapping;
1626
+ }
1485
1627
  let closest;
1486
1628
  let shortest = Number.POSITIVE_INFINITY;
1487
1629
  for (const name of declared) {
@@ -1495,24 +1637,37 @@ function nearestArgument(key, declared) {
1495
1637
  }
1496
1638
  function editDistance(left, right) {
1497
1639
  let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
1640
+ let distance = right.length;
1498
1641
  for (let row = 1; row <= left.length; row += 1) {
1499
1642
  const current = [row];
1500
- for (let column = 1; column <= right.length; column += 1) {
1501
- const substitution = previous[column - 1] + (left[row - 1] === right[column - 1] ? 0 : 1);
1502
- current[column] = Math.min(substitution, previous[column] + 1, current[column - 1] + 1);
1643
+ let diagonal = row - 1;
1644
+ let leftward = row;
1645
+ for (const [column, above] of previous.entries()) {
1646
+ if (column === 0) {
1647
+ continue;
1648
+ }
1649
+ const substitution = diagonal + (left[row - 1] === right[column - 1] ? 0 : 1);
1650
+ const cell = Math.min(substitution, above + 1, leftward + 1);
1651
+ current.push(cell);
1652
+ diagonal = above;
1653
+ leftward = cell;
1503
1654
  }
1504
1655
  previous = current;
1656
+ distance = leftward;
1505
1657
  }
1506
- return previous[right.length];
1658
+ return distance;
1507
1659
  }
1508
1660
 
1509
1661
  // src/tools/shared.ts
1510
1662
  import { z as z2 } from "zod";
1663
+ var ISO_DAY = /^\d{4}-\d{2}-\d{2}$/;
1664
+ var BRACKETED_TAIL = /\(([^()]*)\)\s*$/;
1665
+ var YEAR = /^\d{4}$/;
1511
1666
  var MAX_TEXT_CHARS = 2400;
1512
1667
  var SOURCE_NAME = "data.bnf.fr (Biblioth\xE8que nationale de France)";
1513
1668
  function retrievalDay(retrievedAt) {
1514
1669
  const day = retrievedAt.slice(0, 10);
1515
- return /^\d{4}-\d{2}-\d{2}$/.test(day) ? day : retrievedAt;
1670
+ return ISO_DAY.test(day) ? day : retrievedAt;
1516
1671
  }
1517
1672
  function attribution(retrievedAt, sourceUrl) {
1518
1673
  const credit = `Source: ${SOURCE_NAME}, retrieved ${retrievalDay(retrievedAt)}`;
@@ -1544,7 +1699,9 @@ function onOneLine(value) {
1544
1699
  function ok(structured, body, options) {
1545
1700
  const credit = attribution(options.retrievedAt, options.sourceUrl);
1546
1701
  const noteLines = (options.notes ?? []).map((note) => `Note: ${onOneLine(note)}`);
1547
- while (noteLines.length > 0 && noteLines.join("\n").length > MAX_TEXT_CHARS / 2) noteLines.pop();
1702
+ while (noteLines.length > 0 && noteLines.join("\n").length > MAX_TEXT_CHARS / 2) {
1703
+ noteLines.pop();
1704
+ }
1548
1705
  const trailer = [...noteLines, credit].join("\n");
1549
1706
  const cut = "\n\n[shortened; the full result is in the structured output]";
1550
1707
  const budget = MAX_TEXT_CHARS - `
@@ -1561,11 +1718,15 @@ ${trailer}`;
1561
1718
  function toToolError(error) {
1562
1719
  const known = error instanceof BnfError ? error : new BnfError("network_error", error instanceof Error ? error.message : String(error));
1563
1720
  const lines = [`[${known.code}] ${onOneLine(known.message)}`];
1564
- if (known.details.hint) lines.push(`Hint: ${onOneLine(known.details.hint)}`);
1721
+ if (known.details.hint) {
1722
+ lines.push(`Hint: ${onOneLine(known.details.hint)}`);
1723
+ }
1565
1724
  return { content: [{ type: "text", text: lines.join("\n") }], isError: true };
1566
1725
  }
1567
1726
  function truncate(text2, maxChars) {
1568
- if (text2.length <= maxChars) return text2;
1727
+ if (text2.length <= maxChars) {
1728
+ return text2;
1729
+ }
1569
1730
  return `${text2.slice(0, Math.max(0, maxChars - 1)).trimEnd()}\u2026`;
1570
1731
  }
1571
1732
  var WINDOW_FULL_CAVEAT = "The window this search reads off the index came back full, so records carrying these words sit past what was read and this list is a part of the matches. Which records fill a full window is settled by the index as it answers, so another reading of the same search can bring back other rows. Requiring one more word narrows the reading and reaches what a full window leaves out.";
@@ -1596,7 +1757,9 @@ function readingNotes(reading, field) {
1596
1757
  }
1597
1758
  var NO_RANKING = "The full-text index answers whether a record matches, and it does not score how well. Rows are ordered by the address of the record, which measures nothing, so the row a person would call the obvious answer can sit anywhere in the list, or on a later page. That order is the one the pages are cut along, so reading page after page reaches every match once.";
1598
1759
  async function classifyEmptyPage(page2, firstPageHasRow) {
1599
- if (page2 === 1) return "absent";
1760
+ if (page2 === 1) {
1761
+ return "absent";
1762
+ }
1600
1763
  try {
1601
1764
  return await firstPageHasRow() ? "past_the_end" : "absent";
1602
1765
  } catch {
@@ -1609,18 +1772,24 @@ var WORK_TYPES = [
1609
1772
  "http://rdaregistry.info/Elements/c/#C10001"
1610
1773
  ];
1611
1774
  function recordKindOf(types) {
1612
- if (types.includes(PERSON_TYPE)) return "person";
1613
- if (types.some((type) => WORK_TYPES.includes(type))) return "work";
1775
+ if (types.includes(PERSON_TYPE)) {
1776
+ return "person";
1777
+ }
1778
+ if (types.some((type) => WORK_TYPES.includes(type))) {
1779
+ return "work";
1780
+ }
1614
1781
  return null;
1615
1782
  }
1616
1783
  function headingYears(label) {
1617
- const bracketed = /\(([^()]*)\)\s*$/.exec(label);
1784
+ const bracketed = BRACKETED_TAIL.exec(label);
1618
1785
  const [birth, death] = (bracketed?.[1] ?? "").split("-");
1619
- const year = (part) => part !== void 0 && /^\d{4}$/.test(part.trim()) ? Number(part.trim()) : null;
1786
+ const year = (part) => part !== void 0 && YEAR.test(part.trim()) ? Number(part.trim()) : null;
1620
1787
  return { birth: year(birth), death: year(death) };
1621
1788
  }
1622
1789
  function headingYearConflicts(label, birthYear, deathYear) {
1623
- if (label === null) return [];
1790
+ if (label === null) {
1791
+ return [];
1792
+ }
1624
1793
  const stated = headingYears(label);
1625
1794
  const conflicts = [];
1626
1795
  if (stated.birth !== null && birthYear !== null && stated.birth !== birthYear) {
@@ -1636,6 +1805,37 @@ var ALIGNMENTS_CAVEAT = "The addresses under 'same_as' are the BnF's own alignme
1636
1805
  var PROVISIONAL_CAVEAT = "A provisional record is one the BnF created to hold a title it has catalogued and not yet established as a work of its own. Its identifier can change, and it can be merged into an established record.";
1637
1806
 
1638
1807
  // src/tools/findDigitised.ts
1808
+ async function whichKindTheCatalogueDescribes(client, id) {
1809
+ if (id.kind === "temp-work") {
1810
+ return { kind: "work" };
1811
+ }
1812
+ const types = await client.types(id);
1813
+ if (types.data.length === 0) {
1814
+ return {
1815
+ error: toToolError(
1816
+ notFound(`data.bnf.fr describes nothing at "${id.id}".`, {
1817
+ hint: "Find the identifier with search_authors or search_works rather than writing one.",
1818
+ url: id.pageUrl
1819
+ })
1820
+ )
1821
+ };
1822
+ }
1823
+ const found = recordKindOf(types.data);
1824
+ if (found === null) {
1825
+ return {
1826
+ error: toToolError(
1827
+ notFound(
1828
+ `"${id.id}" is described by data.bnf.fr, and it is neither a person nor a work: it is typed ${types.data.join(", ")}.`,
1829
+ {
1830
+ hint: "This tool follows a person or a work. For one edition, list_editions carries the digitised copies alongside the imprint.",
1831
+ url: id.pageUrl
1832
+ }
1833
+ )
1834
+ )
1835
+ };
1836
+ }
1837
+ return { kind: found };
1838
+ }
1639
1839
  var findDigitisedDescription = [
1640
1840
  "Gather the digitised documents the Biblioth\xE8que nationale de France catalogue attaches to one person or one work, and return them as links.",
1641
1841
  "Give the identifier of either; the tool asks the catalogue what kind of record it is and follows the right path. For a work it walks the editions; for a person it takes the images on the record and the digitised editions of the works they are credited with. The 'kind' returned is what the catalogue types the record as.",
@@ -1669,35 +1869,13 @@ var findDigitisedOutput = z3.object({
1669
1869
  });
1670
1870
  async function runFindDigitised(client, args) {
1671
1871
  try {
1672
- const id = client.identify(args.id);
1872
+ const id = parseEntityId(args.id);
1673
1873
  const notes = [];
1674
- let kind;
1675
- if (id.kind === "temp-work") {
1676
- kind = "work";
1677
- } else {
1678
- const types = await client.types(id);
1679
- if (types.data.length === 0) {
1680
- return toToolError(
1681
- notFound(`data.bnf.fr describes nothing at "${id.id}".`, {
1682
- hint: "Find the identifier with search_authors or search_works rather than writing one.",
1683
- url: id.pageUrl
1684
- })
1685
- );
1686
- }
1687
- const found = recordKindOf(types.data);
1688
- if (found === null) {
1689
- return toToolError(
1690
- notFound(
1691
- `"${id.id}" is described by data.bnf.fr, and it is neither a person nor a work: it is typed ${types.data.join(", ")}.`,
1692
- {
1693
- hint: "This tool follows a person or a work. For one edition, list_editions carries the digitised copies alongside the imprint.",
1694
- url: id.pageUrl
1695
- }
1696
- )
1697
- );
1698
- }
1699
- kind = found;
1874
+ const settled = await whichKindTheCatalogueDescribes(client, id);
1875
+ if ("error" in settled) {
1876
+ return settled.error;
1700
1877
  }
1878
+ const kind = settled.kind;
1701
1879
  if (args.kind !== "auto" && args.kind !== kind) {
1702
1880
  return toToolError(
1703
1881
  invalidInput(
@@ -1707,7 +1885,9 @@ async function runFindDigitised(client, args) {
1707
1885
  );
1708
1886
  }
1709
1887
  const { data, cached, retrievedAt } = kind === "person" ? await client.digitisedForPerson(id, args.limit) : await client.digitisedForWork(id, args.limit);
1710
- if (cached) notes.push("Served from this server's short-lived in-memory cache.");
1888
+ if (cached) {
1889
+ notes.push("Served from this server's short-lived in-memory cache.");
1890
+ }
1711
1891
  const links = data.rows.map((link) => ({
1712
1892
  ark: link.ark,
1713
1893
  url: link.url,
@@ -1722,7 +1902,9 @@ async function runFindDigitised(client, args) {
1722
1902
  depiction: countRole(data.rows, "depiction")
1723
1903
  };
1724
1904
  const rendered = links.filter((link) => link.rendering !== null);
1725
- if (links.length > 0) notes.push(GALLICA_CAVEAT);
1905
+ if (links.length > 0) {
1906
+ notes.push(GALLICA_CAVEAT);
1907
+ }
1726
1908
  if (counts.depiction > 0) {
1727
1909
  notes.push(
1728
1910
  `${counts.depiction === 1 ? "One of these is an image" : `${counts.depiction} of these are images`} the catalogue uses to illustrate the record. Such an image can be a portrait, a title page, or a newspaper page that mentions the subject in passing, and the catalogue does not say which.`
@@ -1777,6 +1959,45 @@ var countRole = (links, role) => links.filter((link) => link.role === role).leng
1777
1959
 
1778
1960
  // src/tools/getAuthor.ts
1779
1961
  import { z as z4 } from "zod";
1962
+ function notesOnWhatTheRecordStates(data, args) {
1963
+ const notes = [];
1964
+ if (data.biographicalInformation !== null) {
1965
+ notes.push(
1966
+ `The BnF states "${data.biographicalInformation}" as this person's biographical information. That field holds an occupation rather than a life, and this is all the catalogue says.`
1967
+ );
1968
+ }
1969
+ if (data.biographicalInformation !== null && data.biographicalInformation === data.occupation) {
1970
+ notes.push(
1971
+ "This record states the same text under 'biographical_information' and 'occupation', character for character. It is one statement written in two fields rather than two the record makes separately."
1972
+ );
1973
+ }
1974
+ const headings = [data.label, ...data.otherNames.map((other) => other.label)];
1975
+ const conflicts = [
1976
+ ...new Set(
1977
+ headings.flatMap((heading) => headingYearConflicts(heading, data.birthYear, data.deathYear))
1978
+ )
1979
+ ];
1980
+ if (conflicts.length > 0) {
1981
+ notes.push(
1982
+ `This record states its dates twice and the two disagree: the heading says ${conflicts.join(", and ")}. Both are passed on as the BnF publishes them, and the record says nothing about which is right, so read source_url before citing either.`
1983
+ );
1984
+ }
1985
+ if (data.deathDate === null && data.deathYear === null) {
1986
+ const dated = data.birthDate !== null || data.birthYear !== null;
1987
+ notes.push(
1988
+ dated ? "The record states no date of death. That can mean the person is living, or that the BnF has not recorded one; the catalogue does not distinguish the two." : `This record states no date of birth and no date of death, so it dates the person nowhere and says nothing about whether they are living. ${SEVERAL_RECORDS_CAVEAT}`
1989
+ );
1990
+ }
1991
+ if (Object.keys(data.sameAs).length > 0) {
1992
+ notes.push(
1993
+ `${ALIGNMENTS_CAVEAT} Following one is how to reach a biography, which this catalogue does not hold.`
1994
+ );
1995
+ }
1996
+ if (args.include_depictions && data.depictions.length > 0) {
1997
+ notes.push(GALLICA_CAVEAT);
1998
+ }
1999
+ return notes;
2000
+ }
1780
2001
  var getAuthorDescription = [
1781
2002
  "Read one person's record in the Biblioth\xE8que nationale de France authority file, by the identifier search_authors returns.",
1782
2003
  "It carries the dates and places of birth and death, the occupation the record names, the language and country it associates with the person, the field of activity with its Dewey class, and the addresses for the same person in VIAF, IdRef, DBpedia, Wikidata and ISNI.",
@@ -1831,45 +2052,13 @@ var getAuthorOutput = z4.object({
1831
2052
  });
1832
2053
  async function runGetAuthor(client, args) {
1833
2054
  try {
1834
- const id = client.identify(args.author_id);
2055
+ const id = parseEntityId(args.author_id);
1835
2056
  const { data, cached, retrievedAt } = await client.getAuthor(id);
1836
2057
  const notes = [];
1837
- if (cached) notes.push("Served from this server's short-lived in-memory cache.");
1838
- if (data.biographicalInformation !== null) {
1839
- notes.push(
1840
- `The BnF states "${data.biographicalInformation}" as this person's biographical information. That field holds an occupation rather than a life, and this is all the catalogue says.`
1841
- );
2058
+ if (cached) {
2059
+ notes.push("Served from this server's short-lived in-memory cache.");
1842
2060
  }
1843
- if (data.biographicalInformation !== null && data.biographicalInformation === data.occupation) {
1844
- notes.push(
1845
- "This record states the same text under 'biographical_information' and 'occupation', character for character. It is one statement written in two fields rather than two the record makes separately."
1846
- );
1847
- }
1848
- const headings = [data.label, ...data.otherNames.map((other) => other.label)];
1849
- const conflicts = [
1850
- ...new Set(
1851
- headings.flatMap(
1852
- (heading) => headingYearConflicts(heading, data.birthYear, data.deathYear)
1853
- )
1854
- )
1855
- ];
1856
- if (conflicts.length > 0) {
1857
- notes.push(
1858
- `This record states its dates twice and the two disagree: the heading says ${conflicts.join(", and ")}. Both are passed on as the BnF publishes them, and the record says nothing about which is right, so read source_url before citing either.`
1859
- );
1860
- }
1861
- if (data.deathDate === null && data.deathYear === null) {
1862
- const dated = data.birthDate !== null || data.birthYear !== null;
1863
- notes.push(
1864
- dated ? "The record states no date of death. That can mean the person is living, or that the BnF has not recorded one; the catalogue does not distinguish the two." : `This record states no date of birth and no date of death, so it dates the person nowhere and says nothing about whether they are living. ${SEVERAL_RECORDS_CAVEAT}`
1865
- );
1866
- }
1867
- if (Object.keys(data.sameAs).length > 0) {
1868
- notes.push(
1869
- `${ALIGNMENTS_CAVEAT} Following one is how to reach a biography, which this catalogue does not hold.`
1870
- );
1871
- }
1872
- if (args.include_depictions && data.depictions.length > 0) notes.push(GALLICA_CAVEAT);
2061
+ notes.push(...notesOnWhatTheRecordStates(data, args));
1873
2062
  const structured = {
1874
2063
  author: {
1875
2064
  id: data.id,
@@ -1911,11 +2100,11 @@ async function runGetAuthor(client, args) {
1911
2100
  }));
1912
2101
  }
1913
2102
  const born = [
1914
- data.birthDate ?? (data.birthYear !== null ? String(data.birthYear) : null),
2103
+ data.birthDate ?? (data.birthYear === null ? null : String(data.birthYear)),
1915
2104
  data.birthPlace
1916
2105
  ].filter(Boolean).join(", ");
1917
2106
  const died = [
1918
- data.deathDate ?? (data.deathYear !== null ? String(data.deathYear) : null),
2107
+ data.deathDate ?? (data.deathYear === null ? null : String(data.deathYear)),
1919
2108
  data.deathPlace
1920
2109
  ].filter(Boolean).join(", ");
1921
2110
  const lines = [
@@ -1939,6 +2128,43 @@ async function runGetAuthor(client, args) {
1939
2128
 
1940
2129
  // src/tools/getWork.ts
1941
2130
  import { z as z5 } from "zod";
2131
+ function notesOnWhatTheRecordStates2(data, args) {
2132
+ const notes = [];
2133
+ if (data.status === "provisional") {
2134
+ notes.push(
2135
+ `This is a provisional record, addressed under 'temp-work' rather than by an ARK${data.statusStatement ? ` and stated as "${data.statusStatement}"` : ""}. ${PROVISIONAL_CAVEAT}`
2136
+ );
2137
+ } else {
2138
+ notes.push(
2139
+ `This is an established work record, addressed by the ARK '${data.id}'${data.statusStatement ? ` and stated as "${data.statusStatement}"` : ""}. That identifier is the one to cite.`
2140
+ );
2141
+ }
2142
+ if (data.truncated) {
2143
+ notes.push(
2144
+ "This record is longer than one query reads, so parts of it are missing from this answer. A field shown as empty here may be filled on the record itself: open source_url before concluding that the BnF states nothing."
2145
+ );
2146
+ }
2147
+ if (data.expressionCount !== null && data.expressionCount > 0) {
2148
+ notes.push(
2149
+ `The record links ${data.expressionCount} expression(s): translations, adaptations and recordings of this work. Published editions are a separate count, and list_editions is what reads them.`
2150
+ );
2151
+ }
2152
+ if (data.forms.length > 0) {
2153
+ notes.push(FORMS_CAVEAT);
2154
+ }
2155
+ if (data.creators.length === 0) {
2156
+ notes.push(
2157
+ "The record credits nobody with this work. That is normal for anonymous and traditional works, and it also happens on records a cataloguer has not finished."
2158
+ );
2159
+ }
2160
+ if (Object.keys(data.sameAs).length > 0) {
2161
+ notes.push(ALIGNMENTS_CAVEAT);
2162
+ }
2163
+ if (args.include_depictions && data.depictions.length > 0) {
2164
+ notes.push(GALLICA_CAVEAT);
2165
+ }
2166
+ return notes;
2167
+ }
1942
2168
  var getWorkDescription = [
1943
2169
  "Read one work's record in the Biblioth\xE8que nationale de France catalogue, by the identifier search_works returns.",
1944
2170
  "It carries the title, everyone the record credits, the date the BnF gives the work, its language, its form and its subject with the Dewey class.",
@@ -1988,37 +2214,13 @@ var getWorkOutput = z5.object({
1988
2214
  });
1989
2215
  async function runGetWork(client, args) {
1990
2216
  try {
1991
- const id = client.identify(args.work_id);
2217
+ const id = parseEntityId(args.work_id);
1992
2218
  const { data, cached, retrievedAt } = await client.getWork(id);
1993
2219
  const notes = [];
1994
- if (cached) notes.push("Served from this server's short-lived in-memory cache.");
1995
- if (data.status === "provisional") {
1996
- notes.push(
1997
- `This is a provisional record, addressed under 'temp-work' rather than by an ARK${data.statusStatement ? ` and stated as "${data.statusStatement}"` : ""}. ${PROVISIONAL_CAVEAT}`
1998
- );
1999
- } else {
2000
- notes.push(
2001
- `This is an established work record, addressed by the ARK '${data.id}'${data.statusStatement ? ` and stated as "${data.statusStatement}"` : ""}. That identifier is the one to cite.`
2002
- );
2003
- }
2004
- if (data.truncated) {
2005
- notes.push(
2006
- "This record is longer than one query reads, so parts of it are missing from this answer. A field shown as empty here may be filled on the record itself: open source_url before concluding that the BnF states nothing."
2007
- );
2008
- }
2009
- if (data.expressionCount !== null && data.expressionCount > 0) {
2010
- notes.push(
2011
- `The record links ${data.expressionCount} expression(s): translations, adaptations and recordings of this work. Published editions are a separate count, and list_editions is what reads them.`
2012
- );
2220
+ if (cached) {
2221
+ notes.push("Served from this server's short-lived in-memory cache.");
2013
2222
  }
2014
- if (data.forms.length > 0) notes.push(FORMS_CAVEAT);
2015
- if (data.creators.length === 0) {
2016
- notes.push(
2017
- "The record credits nobody with this work. That is normal for anonymous and traditional works, and it also happens on records a cataloguer has not finished."
2018
- );
2019
- }
2020
- if (Object.keys(data.sameAs).length > 0) notes.push(ALIGNMENTS_CAVEAT);
2021
- if (args.include_depictions && data.depictions.length > 0) notes.push(GALLICA_CAVEAT);
2223
+ notes.push(...notesOnWhatTheRecordStates2(data, args));
2022
2224
  const structured = {
2023
2225
  work: {
2024
2226
  id: data.id,
@@ -2069,6 +2271,12 @@ async function runGetWork(client, args) {
2069
2271
 
2070
2272
  // src/tools/listEditions.ts
2071
2273
  import { z as z6 } from "zod";
2274
+ function nothingOnThisPage(page2, work) {
2275
+ if (page2 > 1) {
2276
+ return `Page ${page2} of the editions of "${work}" holds no row.`;
2277
+ }
2278
+ return `The BnF catalogue links no edition to the work "${work}".`;
2279
+ }
2072
2280
  var listEditionsDescription = [
2073
2281
  "List the published editions of one work in the Biblioth\xE8que nationale de France catalogue, by the identifier search_works or get_work returns.",
2074
2282
  "Each row carries the publisher, the place, the year, the edition statement, the extent in the words of the record, the ISBN when there is one, and the record in the BnF general catalogue.",
@@ -2112,11 +2320,13 @@ var listEditionsOutput = z6.object({
2112
2320
  });
2113
2321
  async function runListEditions(client, args) {
2114
2322
  try {
2115
- const id = client.identify(args.work_id);
2323
+ const id = parseEntityId(args.work_id);
2116
2324
  const offset = (args.page - 1) * args.limit;
2117
2325
  const { data, cached, retrievedAt } = await client.listEditions(id, args.limit, offset);
2118
2326
  const notes = [];
2119
- if (cached) notes.push("Served from this server's short-lived in-memory cache.");
2327
+ if (cached) {
2328
+ notes.push("Served from this server's short-lived in-memory cache.");
2329
+ }
2120
2330
  const editions = data.rows.map((row) => ({
2121
2331
  id: row.id,
2122
2332
  title: row.title,
@@ -2140,7 +2350,9 @@ async function runListEditions(client, args) {
2140
2350
  source_url: row.sourceUrl
2141
2351
  }));
2142
2352
  const digitisedCount = editions.filter((edition) => edition.digitised.length > 0).length;
2143
- if (digitisedCount > 0) notes.push(GALLICA_CAVEAT);
2353
+ if (digitisedCount > 0) {
2354
+ notes.push(GALLICA_CAVEAT);
2355
+ }
2144
2356
  const bracketed = editions.some(
2145
2357
  (edition) => (edition.publisher?.includes("[") ?? false) || (edition.place?.includes("[") ?? false)
2146
2358
  );
@@ -2167,7 +2379,7 @@ async function runListEditions(client, args) {
2167
2379
  "Rows are ordered by the address the catalogue gives each edition. It is neither chronological nor an order of importance, so a first edition can appear anywhere in the list. That order is the one the pages are cut along, so reading page after page reaches every edition once."
2168
2380
  );
2169
2381
  }
2170
- const body = editions.length === 0 ? args.page > 1 ? `Page ${args.page} of the editions of "${id.id}" holds no row.` : `The BnF catalogue links no edition to the work "${id.id}".` : `${editions.length} edition(s) of "${id.id}":
2382
+ const body = editions.length === 0 ? nothingOnThisPage(args.page, id.id) : `${editions.length} edition(s) of "${id.id}":
2171
2383
  ${editions.map((edition, index) => {
2172
2384
  const imprint = [edition.place, edition.publisher, edition.date].filter(Boolean).join(" : ");
2173
2385
  const extra = [
@@ -2199,7 +2411,9 @@ ${editions.map((edition, index) => {
2199
2411
  }
2200
2412
  }
2201
2413
  async function assertWork(client, id) {
2202
- if (id.kind === "temp-work") return;
2414
+ if (id.kind === "temp-work") {
2415
+ return;
2416
+ }
2203
2417
  const types = await client.types(id);
2204
2418
  if (types.data.length === 0) {
2205
2419
  throw notFound(`data.bnf.fr describes nothing at "${id.id}".`, {
@@ -2281,11 +2495,13 @@ function emptyPageBody(emptiness, page2, authorId) {
2281
2495
  }
2282
2496
  async function runListWorks(client, args) {
2283
2497
  try {
2284
- const id = client.identify(args.author_id);
2498
+ const id = parseEntityId(args.author_id);
2285
2499
  const offset = (args.page - 1) * args.limit;
2286
2500
  const { data, cached, retrievedAt } = await client.worksByAuthor(id, args.limit, offset);
2287
2501
  const notes = [];
2288
- if (cached) notes.push("Served from this server's short-lived in-memory cache.");
2502
+ if (cached) {
2503
+ notes.push("Served from this server's short-lived in-memory cache.");
2504
+ }
2289
2505
  const works = data.rows.map((row) => ({
2290
2506
  id: row.id,
2291
2507
  title: row.title,
@@ -2307,7 +2523,9 @@ async function runListWorks(client, args) {
2307
2523
  const first = await client.worksByAuthor(id, 1, 0);
2308
2524
  return first.data.rows.length > 0;
2309
2525
  });
2310
- if (emptiness === "absent") await assertPerson(client, id);
2526
+ if (emptiness === "absent") {
2527
+ await assertPerson(client, id);
2528
+ }
2311
2529
  notes.push(emptyPageNote(emptiness, args.page, id.id));
2312
2530
  } else {
2313
2531
  if (data.hasMore) {
@@ -2316,7 +2534,9 @@ async function runListWorks(client, args) {
2316
2534
  );
2317
2535
  }
2318
2536
  notes.push(`${FORMS_CAVEAT} ${FORMS_FILTER_CAVEAT}`);
2319
- if (works.some((work) => work.status === "provisional")) notes.push(PROVISIONAL_CAVEAT);
2537
+ if (works.some((work) => work.status === "provisional")) {
2538
+ notes.push(PROVISIONAL_CAVEAT);
2539
+ }
2320
2540
  notes.push(ORDER_CAVEAT, DATE_CAVEAT);
2321
2541
  }
2322
2542
  const body = works.length === 0 ? emptyPageBody(emptiness, args.page, id.id) : `${works.length} work(s) the catalogue links to "${id.id}" as their creator:
@@ -2364,6 +2584,7 @@ async function assertPerson(client, id) {
2364
2584
 
2365
2585
  // src/tools/searchAuthors.ts
2366
2586
  import { z as z8 } from "zod";
2587
+ var JOINER = /['-]/u;
2367
2588
  var searchAuthorsDescription = [
2368
2589
  "Find a person in the Biblioth\xE8que nationale de France authority file by name, and get the identifier the other tools take.",
2369
2590
  "This matches the name the BnF records, so it takes a surname, a full name or both names in either order. It does not read biographies, so it cannot find a person from what they wrote or what they did.",
@@ -2407,9 +2628,15 @@ var searchAuthorsOutput = z8.object({
2407
2628
  });
2408
2629
  var SPELLING_CAVEAT = "The index matches the words letter for letter, so a name written with other accents, or under another transliteration, is a different search reaching different records. A short answer is no evidence that the file holds nobody else of that name: ask again with the accents dropped, with them restored, and under the transliterations a library uses.";
2409
2630
  function lifespan(birthYear, deathYear) {
2410
- if (birthYear !== null && deathYear !== null) return ` (${birthYear}\u2013${deathYear})`;
2411
- if (birthYear !== null) return ` (born ${birthYear})`;
2412
- if (deathYear !== null) return ` (died ${deathYear})`;
2631
+ if (birthYear !== null && deathYear !== null) {
2632
+ return ` (${birthYear}\u2013${deathYear})`;
2633
+ }
2634
+ if (birthYear !== null) {
2635
+ return ` (born ${birthYear})`;
2636
+ }
2637
+ if (deathYear !== null) {
2638
+ return ` (died ${deathYear})`;
2639
+ }
2413
2640
  return "";
2414
2641
  }
2415
2642
  function emptyPageNote2(emptiness, page2, words, windowFull) {
@@ -2439,7 +2666,7 @@ function emptyPageBody2(emptiness, page2, name, windowFull) {
2439
2666
  }
2440
2667
  async function runSearchAuthors(client, args) {
2441
2668
  try {
2442
- const reading = client.searchReading(args.name);
2669
+ const reading = readSearchText(args.name);
2443
2670
  const words = reading.words;
2444
2671
  if (words.length === 0) {
2445
2672
  return toToolError(
@@ -2462,12 +2689,16 @@ async function runSearchAuthors(client, args) {
2462
2689
  const terms = reading.terms;
2463
2690
  const windowFull = data.indexWindowFull ?? null;
2464
2691
  const notes = [];
2465
- if (cached) notes.push("Served from this server's short-lived in-memory cache.");
2692
+ if (cached) {
2693
+ notes.push("Served from this server's short-lived in-memory cache.");
2694
+ }
2466
2695
  if (windowFull) {
2467
2696
  notes.push(WINDOW_FULL_CAVEAT);
2468
- if (!data.hasMore) notes.push(WINDOW_FULL_AND_NO_MORE);
2697
+ if (!data.hasMore) {
2698
+ notes.push(WINDOW_FULL_AND_NO_MORE);
2699
+ }
2469
2700
  }
2470
- const split = words.filter((word) => /['-]/u.test(word));
2701
+ const split = words.filter((word) => JOINER.test(word));
2471
2702
  if (split.length > 0) {
2472
2703
  notes.push(
2473
2704
  `${split.map((word) => `"${word}"`).join(", ")} reached the index as separate terms: it cuts a word at an apostrophe and at a hyphen, and requires each piece to appear in the name rather than in that arrangement, so a record writing the pieces apart is a match.`
@@ -2483,7 +2714,9 @@ async function runSearchAuthors(client, args) {
2483
2714
  role: row.role,
2484
2715
  source_url: row.sourceUrl
2485
2716
  }));
2486
- if (authors.length > 0) notes.push(NO_RANKING);
2717
+ if (authors.length > 0) {
2718
+ notes.push(NO_RANKING);
2719
+ }
2487
2720
  notes.push(SPELLING_CAVEAT);
2488
2721
  const disputed = authors.map((author) => ({
2489
2722
  label: author.label ?? author.name,
@@ -2497,7 +2730,9 @@ async function runSearchAuthors(client, args) {
2497
2730
  const repeated = /* @__PURE__ */ new Map();
2498
2731
  for (const author of authors) {
2499
2732
  const key = (author.name ?? "").toLocaleLowerCase("fr");
2500
- if (key !== "") repeated.set(key, (repeated.get(key) ?? 0) + 1);
2733
+ if (key !== "") {
2734
+ repeated.set(key, (repeated.get(key) ?? 0) + 1);
2735
+ }
2501
2736
  }
2502
2737
  const shared = [...repeated.entries()].filter(([, count]) => count > 1);
2503
2738
  if (shared.length > 0) {
@@ -2546,6 +2781,7 @@ ${authors.map((author, index) => {
2546
2781
 
2547
2782
  // src/tools/searchWorks.ts
2548
2783
  import { z as z9 } from "zod";
2784
+ var JOINER2 = /['-]/u;
2549
2785
  var searchWorksDescription = [
2550
2786
  "Find a work in the Biblioth\xE8que nationale de France catalogue by words in its title, and get the identifier get_work and list_editions take.",
2551
2787
  "A row matches when its title carries every word given. The index returns no measure of how well a row matches, so the rows are ordered by the address of the record and the work a person would call the obvious answer can sit anywhere in the list or on a later page.",
@@ -2612,7 +2848,7 @@ function emptyPageBody3(emptiness, page2, title, windowFull) {
2612
2848
  }
2613
2849
  async function runSearchWorks(client, args) {
2614
2850
  try {
2615
- const reading = client.searchReading(args.title);
2851
+ const reading = readSearchText(args.title);
2616
2852
  const words = reading.words;
2617
2853
  if (words.length === 0) {
2618
2854
  return toToolError(
@@ -2635,12 +2871,16 @@ async function runSearchWorks(client, args) {
2635
2871
  const terms = reading.terms;
2636
2872
  const windowFull = data.indexWindowFull ?? null;
2637
2873
  const notes = [];
2638
- if (cached) notes.push("Served from this server's short-lived in-memory cache.");
2874
+ if (cached) {
2875
+ notes.push("Served from this server's short-lived in-memory cache.");
2876
+ }
2639
2877
  if (windowFull) {
2640
2878
  notes.push(WINDOW_FULL_CAVEAT);
2641
- if (!data.hasMore) notes.push(WINDOW_FULL_AND_NO_MORE);
2879
+ if (!data.hasMore) {
2880
+ notes.push(WINDOW_FULL_AND_NO_MORE);
2881
+ }
2642
2882
  }
2643
- const split = words.filter((word) => /['-]/u.test(word));
2883
+ const split = words.filter((word) => JOINER2.test(word));
2644
2884
  if (split.length > 0) {
2645
2885
  notes.push(
2646
2886
  `${split.map((word) => `"${word}"`).join(", ")} reached the index as separate terms: it cuts a word at an apostrophe and at a hyphen, and requires each piece to appear in the title rather than in that arrangement, so a record writing the pieces apart is a match.`
@@ -2655,8 +2895,12 @@ async function runSearchWorks(client, args) {
2655
2895
  status: row.status,
2656
2896
  source_url: row.sourceUrl
2657
2897
  }));
2658
- if (works.length > 0) notes.push(NO_RANKING);
2659
- if (works.some((work) => work.status === "provisional")) notes.push(PROVISIONAL_CAVEAT);
2898
+ if (works.length > 0) {
2899
+ notes.push(NO_RANKING);
2900
+ }
2901
+ if (works.some((work) => work.status === "provisional")) {
2902
+ notes.push(PROVISIONAL_CAVEAT);
2903
+ }
2660
2904
  if (terms.length > 2) {
2661
2905
  notes.push(
2662
2906
  `All ${terms.length} terms were required: ${terms.map((term) => `"${term}"`).join(", ")}. A record catalogued under a shorter form of the title carries fewer of them and falls out of the answer, so dropping a word widens the search.`
@@ -2828,15 +3072,15 @@ async function main() {
2828
3072
  await server.connect(transport);
2829
3073
  const shutdown = async () => {
2830
3074
  await server.close().catch(() => void 0);
2831
- process.exit(0);
3075
+ process2.exit(0);
2832
3076
  };
2833
- process.on("SIGINT", shutdown);
2834
- process.on("SIGTERM", shutdown);
3077
+ process2.on("SIGINT", shutdown);
3078
+ process2.on("SIGTERM", shutdown);
2835
3079
  }
2836
3080
  main().catch((error) => {
2837
3081
  const message = error instanceof Error ? error.message : String(error);
2838
- process.stderr.write(`[mcp-databnf] fatal: ${message}
3082
+ process2.stderr.write(`[mcp-databnf] fatal: ${message}
2839
3083
  `);
2840
- process.exit(1);
3084
+ process2.exit(1);
2841
3085
  });
2842
3086
  //# sourceMappingURL=index.js.map